From 826b5ae1f233f81bc04ea9f0e26ddbbcb9b45e1f Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Wed, 10 Sep 2025 15:21:49 +0100 Subject: [PATCH 1/4] feat: support dag-json and dag-cbor preview rendering --- package-lock.json | 54 +++--- package.json | 6 +- src/bindings.d.ts | 2 +- src/handlers/block.js | 127 +++++++++++++- src/handlers/templates/block.handlebars | 164 ++++++++++++++++++ .../templates/unixfs-dir-entries.handlebars | 2 +- .../templates/unixfs-dir-header.handlebars | 16 +- src/handlers/unixfs-dir.js | 22 +-- src/handlers/unixfs.js | 19 +- src/util/handlebars.js | 20 +++ src/util/streams.js | 12 ++ 11 files changed, 387 insertions(+), 57 deletions(-) create mode 100644 src/handlers/templates/block.handlebars create mode 100644 src/util/handlebars.js diff --git a/package-lock.json b/package-lock.json index 840cec9..2ffae8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "5.1.3", "license": "Apache-2.0 OR MIT", "dependencies": { + "@gct256/hexdump": "^0.1.2", "@httpland/range-parser": "^1.2.0", "@ipld/car": "^5.2.6", + "@ipld/dag-cbor": "^9.2.5", + "@ipld/dag-json": "^10.2.5", "@web3-storage/handlebars": "^1.0.0", "bytes": "^3.1.2", "chardet": "^2.0.0", @@ -23,13 +26,12 @@ "uint8arrays": "^5.0.1" }, "devDependencies": { - "@ipld/dag-cbor": "^9.0.8", "@ipld/dag-pb": "^4.0.8", "@types/bytes": "^3.1.4", "ipfs-unixfs": "^11.1.2", "ipfs-unixfs-exporter": "^13.3.0", "standard": "^17.1.0", - "typescript": "^5.3.3" + "typescript": "^5.9.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -99,11 +101,6 @@ "resolved": "https://registry.npmjs.org/it-first/-/it-first-3.0.6.tgz", "integrity": "sha512-ExIewyK9kXKNAplg2GMeWfgjUcfC1FnUXz/RPfAvIXby+w7U4b3//5Lic0NV03gXT8O/isj5Nmp6KiY0d45pIQ==" }, - "node_modules/@achingbrain/nat-port-mapper/node_modules/multiformats": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.1.0.tgz", - "integrity": "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ==" - }, "node_modules/@achingbrain/nat-port-mapper/node_modules/uint8-varint": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.4.tgz", @@ -360,6 +357,12 @@ "node": ">=14" } }, + "node_modules/@gct256/hexdump": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@gct256/hexdump/-/hexdump-0.1.2.tgz", + "integrity": "sha512-2ffonPjag1lg7h5rggdNTmfOfRs5fRYw1WIKqR4KmaRVk4L6O7WSInOLC2k2VnHGpq00JXGB6BtZkztxL9YyDg==", + "license": "MIT" + }, "node_modules/@handlebars/parser": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@handlebars/parser/-/parser-1.1.0.tgz", @@ -423,12 +426,13 @@ } }, "node_modules/@ipld/dag-cbor": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-9.0.8.tgz", - "integrity": "sha512-ETWJ7p7lmGw5X+BuI/7rf4/k56xyOvAOVNUVuQmnGYBdJjObLPgS+vyFxRk4odATlkyZqCq2MLNY52bhE6SlRA==", + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-9.2.5.tgz", + "integrity": "sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==", + "license": "Apache-2.0 OR MIT", "dependencies": { "cborg": "^4.0.0", - "multiformats": "^13.0.0" + "multiformats": "^13.1.0" }, "engines": { "node": ">=16.0.0", @@ -436,12 +440,13 @@ } }, "node_modules/@ipld/dag-json": { - "version": "10.1.7", - "resolved": "https://registry.npmjs.org/@ipld/dag-json/-/dag-json-10.1.7.tgz", - "integrity": "sha512-ipraTPMA40sZAtUYwFvjHeQjReDJXWI8V3lrOeyedKxMb9rOOCS0B7eodRoWM3RIS2qMqtnu1oZr8kP+QJEN0Q==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/@ipld/dag-json/-/dag-json-10.2.5.tgz", + "integrity": "sha512-Q4Fr3IBDEN8gkpgNefynJ4U/ZO5Kwr7WSUMBDbZx0c37t0+IwQCTM9yJh8l5L4SRFjm31MuHwniZ/kM+P7GQ3Q==", + "license": "Apache-2.0 OR MIT", "dependencies": { "cborg": "^4.0.0", - "multiformats": "^13.0.0" + "multiformats": "^13.1.0" }, "engines": { "node": ">=16.0.0", @@ -1331,11 +1336,6 @@ "npm": ">=7.0.0" } }, - "node_modules/@libp2p/interface/node_modules/multiformats": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.1.0.tgz", - "integrity": "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ==" - }, "node_modules/@libp2p/interface/node_modules/uint8-varint": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.4.tgz", @@ -6040,9 +6040,10 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multiformats": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.0.1.tgz", - "integrity": "sha512-bt3R5iXe2O8xpp3wkmQhC73b/lC4S2ihU8Dndwcsysqbydqb8N+bpP116qMcClZ17g58iSIwtXUTcg2zT4sniA==" + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.0.tgz", + "integrity": "sha512-Mkb/QcclrJxKC+vrcIFl297h52QcKh2Az/9A5vbWytbQt4225UWWWmIuSsKksdww9NkIeYcA7DkfftyLuC/JSg==", + "license": "Apache-2.0 OR MIT" }, "node_modules/multipart-byte-range": { "version": "2.0.1", @@ -7407,10 +7408,11 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 003ad74..6f89fe1 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,11 @@ "author": "Alan Shaw", "license": "Apache-2.0 OR MIT", "dependencies": { + "@gct256/hexdump": "^0.1.2", "@httpland/range-parser": "^1.2.0", "@ipld/car": "^5.2.6", + "@ipld/dag-cbor": "^9.2.5", + "@ipld/dag-json": "^10.2.5", "@web3-storage/handlebars": "^1.0.0", "bytes": "^3.1.2", "chardet": "^2.0.0", @@ -79,13 +82,12 @@ "uint8arrays": "^5.0.1" }, "devDependencies": { - "@ipld/dag-cbor": "^9.0.8", "@ipld/dag-pb": "^4.0.8", "@types/bytes": "^3.1.4", "ipfs-unixfs": "^11.1.2", "ipfs-unixfs-exporter": "^13.3.0", "standard": "^17.1.0", - "typescript": "^5.3.3" + "typescript": "^5.9.2" }, "publishConfig": { "access": "public" diff --git a/src/bindings.d.ts b/src/bindings.d.ts index 3ac3f24..d1f68a3 100644 --- a/src/bindings.d.ts +++ b/src/bindings.d.ts @@ -3,7 +3,7 @@ import type { UnixFSEntry } from 'ipfs-unixfs-exporter' import type { BlockService, DagService, UnixfsService } from 'dagula' import type { TimeoutController } from 'timeout-abort-controller' -export {} +export { TimeoutController } export interface Environment { DEBUG?: string diff --git a/src/handlers/block.js b/src/handlers/block.js index db88108..9acc8d7 100644 --- a/src/handlers/block.js +++ b/src/handlers/block.js @@ -1,10 +1,22 @@ /* eslint-env browser */ +/** + * @import { Block } from 'dagula' + * @import { BlockDecoder } from 'multiformats' + * @import { IpfsUrlContext, BlockContext, UnixfsContext, TimeoutController } from '../bindings.js' + */ +import * as dagJSON from '@ipld/dag-json' +import * as dagCBOR from '@ipld/dag-cbor' +import './templates/bundle.cjs' import { MultipartByteRange } from 'multipart-byte-range' +import { hexdump } from '@gct256/hexdump' +import { CID } from 'multiformats/cid' import { decodeRangeHeader, resolveRange } from '../util/range.js' import { HttpError } from '../util/errors.js' +import { Handlebars, getTemplate, registerHelper } from '../util/handlebars.js' /** - * @typedef {import('../bindings.js').IpfsUrlContext & import('../bindings.js').BlockContext & import('../bindings.js').UnixfsContext & { timeoutController?: import('../bindings.js').TimeoutControllerContext['timeoutController'] }} BlockHandlerContext + * @typedef {IpfsUrlContext & BlockContext & UnixfsContext & { timeoutController?: TimeoutController }} BlockHandlerContext + * @typedef {IpfsUrlContext & { gatewayDomain?: string, block: Block }} BlockHtmlHandlerContext */ /** @type {import('../bindings.js').Handler} */ @@ -125,3 +137,116 @@ const handleMultipartRange = async (blocks, cid, ranges, options) => { }) return new Response(source, { status: 206, headers: { ...options?.headers, ...source.headers } }) } + +/** @type {Record>} */ +const codecs = { + [dagCBOR.code]: dagCBOR, + [dagJSON.code]: dagJSON +} + +/** @type {Record} */ +const codecNames = { + [dagCBOR.code]: 'dag-cbor', + [dagJSON.code]: 'dag-json' +} + +/** @param {unknown} obj */ +const isIpldScalar = obj => { + switch (typeof obj) { + case 'string': + return true + case 'boolean': + return true + case 'number': + return true + case 'object': { + if (obj == null) { + return true + } + if (obj instanceof Uint8Array) { // IPLD bytes + return true + } + if (obj instanceof CID) { + return true + } + break + } + } + return false +} +registerHelper('isIpldScalar', isIpldScalar) + +/** @param {unknown} obj */ +const isIpldList = obj => Array.isArray(obj) +registerHelper('isIpldList', isIpldList) + +/** @param {unknown} obj */ +const isIpldLink = obj => obj instanceof CID +registerHelper('isIpldLink', isIpldLink) + +/** @param {unknown} obj */ +const isIpldMap = obj => { + return obj != null && typeof obj == 'object' && !isIpldScalar(obj) && !isIpldList(obj) +} +registerHelper('isIpldMap', isIpldMap) + +/** @param {unknown} obj */ +const isIpldBytes = obj => obj instanceof Uint8Array +registerHelper('isIpldBytes', isIpldBytes) + +registerHelper('formatIpldScalar', (/** @type {unknown} */ obj) => { + switch (typeof obj) { + case 'string': + return obj + case 'boolean': + return obj ? 'true' : 'false' + case 'number': + return obj.toString() + case 'object': { + if (obj == null) { + return 'NULL' + } + if (obj instanceof Uint8Array) { + return hexdump(obj).join('\n') + } + break + } + } + return 'UNKNOWN' +}) + +/** @type {import('../bindings.js').Handler} */ +export async function handleBlockHtml (request, env, ctx) { + const { dataCid, path, block } = ctx + if (!dataCid) throw new Error('missing data CID') + if (path == null) throw new Error('missing URL pathname') + if (!block) throw new Error('missing block') + + const codec = codecs[block.cid.code] + if (!codec) { + throw new HttpError('unsupported entry type', { status: 501 }) + } + + const headers = { + 'Content-Type': 'text/html' + } + + if (request.method === 'HEAD') { + return new Response(null, { headers }) + } + if (request.method !== 'GET') { + throw new HttpError('method not allowed', { status: 405 }) + } + + const isSubdomain = new URL(request.url).hostname.includes('.ipfs.') + const html = getTemplate('block')({ + gatewayDomain: ctx.gatewayDomain || 'storacha.link', + path: isSubdomain ? ctx.path : `${ctx.dataCid}/${ctx.path}`, + cid: block.cid, + codecName: codecNames[block.cid.code] ?? 'UNKNOWN', + codecHex: `0x${block.cid.code.toString(16)}`, + value: codec.decode(block.bytes) + }) + + return new Response(html) +} diff --git a/src/handlers/templates/block.handlebars b/src/handlers/templates/block.handlebars new file mode 100644 index 0000000..9a191b6 --- /dev/null +++ b/src/handlers/templates/block.handlebars @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + +{{path}} + + + + + +
+
+
+
+ CID: {{cid}} +
+
+ Codec: {{codecName}} ({{codecHex}}) +
+
+
+
+ + + + + + + + +
+

You can download this block as:

+ +
+ {{codecName}} Preview +
+ {{> renderIpldValue value=value }} +
+
+ + + +{{#*inline "renderIpldValue" value}} + {{#if (isIpldScalar value)}} + {{#if (isIpldLink value)}} + + {{else}} + {{#if (isIpldBytes value)}} +
{{formatIpldScalar value}}
+ {{else}} +
{{formatIpldScalar value}}
+ {{/if}} + {{/if}} + {{/if}} + {{#if (isIpldList value)}} +
+ {{#each value}} +
{{@index}}
+ {{> renderIpldValue value=this}} + {{/each}} +
+ {{/if}} + {{#if (isIpldMap value)}} +
+ {{#each value}} +
{{@key}}
+ {{> renderIpldValue value=this}} + {{/each}} +
+ {{/if}} +{{/inline}} + +{{!-- {{ define "value" }} + {{ $root := index . 0 }} + {{ $value := index . 1 }} + {{ if len $value.Values }} +
+ {{ range $index, $key := $value.Keys }} + {{ template "value" (args $root $key) }} + {{ template "value" (args $root (index $value.Values $index)) }} + {{ end }} +
+ {{ else }} + + {{ end }} +{{ end }} --}} \ No newline at end of file diff --git a/src/handlers/templates/unixfs-dir-entries.handlebars b/src/handlers/templates/unixfs-dir-entries.handlebars index 522543a..a17cdf0 100644 --- a/src/handlers/templates/unixfs-dir-entries.handlebars +++ b/src/handlers/templates/unixfs-dir-entries.handlebars @@ -8,7 +8,7 @@ {{#if hash}} - + {{shortHash hash}} {{/if}} diff --git a/src/handlers/templates/unixfs-dir-header.handlebars b/src/handlers/templates/unixfs-dir-header.handlebars index 5d1566b..5cb450f 100644 --- a/src/handlers/templates/unixfs-dir-header.handlebars +++ b/src/handlers/templates/unixfs-dir-header.handlebars @@ -6,27 +6,27 @@ - + - + - + {{path}} - +