From 8ec6fb8c760c0d811675cbe21deaa458978149f0 Mon Sep 17 00:00:00 2001 From: Jubal Mabaquiao Date: Tue, 26 Dec 2023 12:24:03 +0800 Subject: [PATCH] Ipfs dockerize (#231) IPFS deployment strategy --- .github/workflows/ipfs-deploy.yml | 50 +++++++++++++++++++++ Dockerfile | 46 +++++++++++++++++++ build-scripts/vendor.mts | 75 +++++++++++++++++++++++++++++++ build/package-lock.json | 32 ------------- build/package.json | 10 ----- build/tsconfig.json | 21 --------- build/vendor.ts | 54 ---------------------- package-lock.json | 20 +++++++-- package.json | 9 ++-- tsconfig.json | 2 +- tsconfig.vendor.json | 25 +++++++++++ 11 files changed, 220 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/ipfs-deploy.yml create mode 100644 Dockerfile create mode 100755 build-scripts/vendor.mts delete mode 100644 build/package-lock.json delete mode 100644 build/package.json delete mode 100644 build/tsconfig.json delete mode 100644 build/vendor.ts create mode 100644 tsconfig.vendor.json diff --git a/.github/workflows/ipfs-deploy.yml b/.github/workflows/ipfs-deploy.yml new file mode 100644 index 00000000..9836e82a --- /dev/null +++ b/.github/workflows/ipfs-deploy.yml @@ -0,0 +1,50 @@ +name: Build and Push to IPFS + +on: + push: + # Publish `main` as Docker `latest` image. + branches: + - main + + # Publish `v1.2.3` tags as releases. + tags: + - v* + +jobs: + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + push: + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v2 + + - name: Set IMAGE_TAG + run: echo "IMAGE_TAG=$(echo ${{ github.repository }} | tr '[A-Z]' '[a-z]')" >> $GITHUB_ENV + + - name: Build image + run: | + docker build . --file Dockerfile --tag $IMAGE_TAG + + - name: Log into registry + run: echo "${{ secrets.CONTAINER_REGISTRY }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push image + run: | + IMAGE_ID=ghcr.io/$IMAGE_TAG + + # Strip git ref prefix from version + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + + # Strip "v" prefix from tag name + [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + + # Use Docker `latest` tag convention + [ "$VERSION" == "main" ] && VERSION=latest + + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + + docker tag $IMAGE_TAG $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..83b2be70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM node:20-alpine3.19@sha256:e96618520c7db4c3e082648678ab72a49b73367b9a1e7884cf75ac30a198e454 as builder + +# Install app dependencies +COPY ./package.json /source/package.json +COPY ./package-lock.json /source/package-lock.json +WORKDIR /source +RUN npm ci + +# Run the vendoring script +COPY ./build-scripts/ /source/build-scripts/ +COPY ./tsconfig.vendor.json /source/tsconfig.vendor.json +COPY ./app/index.html /source/app/index.html +RUN npm run vendor + +# Buld the app +COPY ./tsconfig.json /source/tsconfig.json +COPY ./app/css/ /source/app/css/ +COPY ./app/img/ /source/app/img/ +COPY ./app/ts/ /source/app/ts/ +RUN npm run build + +# Cache the kubo image +FROM ipfs/kubo:v0.25.0@sha256:5759933ec4e7c7d491bd3a011b84567f3b254bc7bb16bdf56ac59daa78fe4f29 as ipfs-kubo + +# Create the base image +FROM debian:12.2-slim@sha256:93ff361288a7c365614a5791efa3633ce4224542afb6b53a1790330a8e52fc7d + +# Install kubo and initialize ipfs +COPY --from=ipfs-kubo /usr/local/bin/ipfs /usr/local/bin/ipfs + +RUN ipfs init + +# Copy lunaria build output +COPY --from=builder /source/app /export + +# add the build output to IPFS and write the hash to a file +RUN ipfs add --cid-version 1 --quieter --only-hash --recursive /export > ipfs_hash.txt + +# print the hash for good measure in case someone is looking at the build logs +RUN cat ipfs_hash.txt + +# this entrypoint file will execute `ipfs add` of the build output to the docker host's IPFS API endpoint, so we can easily extract the IPFS build out of the docker image +RUN printf '#!/bin/sh\nipfs --api /ip4/`getent ahostsv4 host.docker.internal | grep STREAM | head -n 1 | cut -d \ -f 1`/tcp/5001 add --cid-version 1 -r /export' >> entrypoint.sh +RUN chmod u+x entrypoint.sh + +ENTRYPOINT [ "./entrypoint.sh" ] diff --git a/build-scripts/vendor.mts b/build-scripts/vendor.mts new file mode 100755 index 00000000..29873ff6 --- /dev/null +++ b/build-scripts/vendor.mts @@ -0,0 +1,75 @@ +import * as path from 'path' +import * as url from 'url' +import { promises as fs } from 'fs' +import { FileType, recursiveDirectoryCopy } from '@zoltu/file-copier' + +const directoryOfThisFile = path.dirname(url.fileURLToPath(import.meta.url)) +const VENDOR_OUTPUT_PATH = path.join(directoryOfThisFile, '..', 'app', 'vendor') +const MODULES_ROOT_PATH = path.join(directoryOfThisFile, '..', 'node_modules') +const INDEX_HTML_PATH = path.join(directoryOfThisFile, '..', 'app', 'index.html') + +type Dependency = { packageName: string; packageToVendor?: string; subfolderToVendor: string; mainEntrypointFile: string; alternateEntrypoints: Record } +const dependencyPaths: Dependency[] = [ + { packageName: 'preact', subfolderToVendor: 'dist', mainEntrypointFile: 'preact.module.js', alternateEntrypoints: {} }, + { packageName: 'preact/jsx-runtime', subfolderToVendor: 'dist', mainEntrypointFile: 'jsxRuntime.module.js', alternateEntrypoints: {} }, + { packageName: 'preact/hooks', subfolderToVendor: 'dist', mainEntrypointFile: 'hooks.module.js', alternateEntrypoints: {} }, + { packageName: 'ethers', subfolderToVendor: 'dist', mainEntrypointFile: 'ethers.js', alternateEntrypoints: {} }, + { packageName: '@preact/signals-core', subfolderToVendor: 'dist', mainEntrypointFile: 'signals-core.module.js', alternateEntrypoints: {} }, + { packageName: '@preact/signals', subfolderToVendor: 'dist', mainEntrypointFile: 'signals.module.js', alternateEntrypoints: {} }, + { packageName: 'funtypes', subfolderToVendor: 'lib', mainEntrypointFile: 'index.mjs', alternateEntrypoints: {} }, +] + +async function vendorDependencies() { + async function inclusionPredicate(path: string, fileType: FileType) { + if (path.endsWith('.js')) return true + if (path.endsWith('.ts')) return true + if (path.endsWith('.mjs')) return true + if (path.endsWith('.mts')) return true + if (path.endsWith('.map')) return true + if (path.endsWith('.git') || path.endsWith('.git/') || path.endsWith('.git\\')) return false + if (path.endsWith('node_modules') || path.endsWith('node_modules/') || path.endsWith('node_modules\\')) return false + if (fileType === 'directory') return true + return false + } + for (const { packageName, packageToVendor, subfolderToVendor } of dependencyPaths) { + const sourceDirectoryPath = path.join(MODULES_ROOT_PATH, packageToVendor || packageName, subfolderToVendor) + const destinationDirectoryPath = path.join(VENDOR_OUTPUT_PATH, packageToVendor || packageName) + await recursiveDirectoryCopy(sourceDirectoryPath, destinationDirectoryPath, inclusionPredicate, rewriteSourceMapSourcePath.bind(undefined, packageName)) + } + + const oldIndexHtml = await fs.readFile(INDEX_HTML_PATH, 'utf8') + const importmap = dependencyPaths.reduce( + (importmap, { packageName, mainEntrypointFile, alternateEntrypoints }) => { + importmap.imports[packageName] = `./vendor/${packageName}/${mainEntrypointFile}` + for (const [alternateEntrypointName, alternateEntrypointFile] of Object.entries(alternateEntrypoints)) { + importmap.imports[`${packageName}/${alternateEntrypointName}`] = `./vendor/${packageName}/${alternateEntrypointFile}` + } + return importmap + }, + { imports: {} as Record } + ) + const importmapJson = JSON.stringify(importmap, undefined, '\t').replace(/^/gm, '\t\t') + const newIndexHtml = oldIndexHtml.replace(/`) + await fs.writeFile(INDEX_HTML_PATH, newIndexHtml) +} + +// rewrite the source paths in sourcemap files so they show up in the debugger in a reasonable location and if two source maps refer to the same (relative) path, we end up with them distinguished in the browser debugger +async function rewriteSourceMapSourcePath(packageName: string, sourcePath: string, destinationPath: string) { + const fileExtension = path.extname(sourcePath) + if (fileExtension !== '.map') return + const fileContents = JSON.parse(await fs.readFile(sourcePath, 'utf-8')) as { sources: Array } + for (let i = 0; i < fileContents.sources.length; ++i) { + const source = fileContents.sources[i] + if (source === undefined) continue + // we want to ensure all source files show up in the appropriate directory and don't leak out of our directory tree, so we strip leading '../' references + const sourcePath = source.replace(/^(?:.\/)*/, '').replace(/^(?:..\/)*/, '') + fileContents.sources[i] = ['dependencies://dependencies', packageName, sourcePath].join('/') + } + await fs.writeFile(destinationPath, JSON.stringify(fileContents)) +} + +vendorDependencies().catch(error => { + console.error(error) + debugger + process.exit(1) +}) diff --git a/build/package-lock.json b/build/package-lock.json deleted file mode 100644 index 60180472..00000000 --- a/build/package-lock.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "build", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "devDependencies": { - "@zoltu/file-copier": "2.2.1", - "typescript": "4.7.3" - } - }, - "node_modules/@zoltu/file-copier": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@zoltu/file-copier/-/file-copier-2.2.1.tgz", - "integrity": "sha512-EsCYPddSwciz80ApK0H/rv+fUZVFly5VkZ1AhjxSDXcte81WkBZp6mP8Ai/2JFWfyPmfJVKEoIVYD5EIPhz4og==", - "dev": true - }, - "node_modules/typescript": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz", - "integrity": "sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - } - } -} diff --git a/build/package.json b/build/package.json deleted file mode 100644 index 6ebfe443..00000000 --- a/build/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "module", - "devDependencies": { - "@zoltu/file-copier": "2.2.1", - "typescript": "4.7.3" - }, - "scripts": { - "vendor": "npx tsx vendor.ts" - } -} diff --git a/build/tsconfig.json b/build/tsconfig.json deleted file mode 100644 index 2b045dc0..00000000 --- a/build/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "ES2020", - "moduleResolution": "node", - "noEmit": true, - "rootDir": ".", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "lib": ["ES2020"], - "typeRoots": ["./node_modules/@types"], - "types": [] - }, - "include": ["./*.ts"], - "ts-node": { - "esm": true - } -} diff --git a/build/vendor.ts b/build/vendor.ts deleted file mode 100644 index 9c64c8b8..00000000 --- a/build/vendor.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as path from 'path' -import * as url from 'url' -import { promises as fs } from 'fs' -import { recursiveDirectoryCopy } from '@zoltu/file-copier' - -const directoryOfThisFile = path.dirname(url.fileURLToPath(import.meta.url)) - -const dependencyPaths = [ - { packageName: 'preact', subfolderToVendor: 'dist', entrypointFile: 'preact.module.js' }, - { packageName: 'preact/jsx-runtime', subfolderToVendor: 'dist', entrypointFile: 'jsxRuntime.module.js' }, - { packageName: 'preact/hooks', subfolderToVendor: 'dist', entrypointFile: 'hooks.module.js' }, - { packageName: 'ethers', subfolderToVendor: 'dist', entrypointFile: 'ethers.js', }, - { packageName: '@preact/signals-core', subfolderToVendor: 'dist', entrypointFile: 'signals-core.module.js', }, - { packageName: '@preact/signals', subfolderToVendor: 'dist', entrypointFile: 'signals.module.js' }, - { packageName: 'funtypes', subfolderToVendor: 'lib', entrypointFile: 'index.mjs' } -] - -async function vendorDependencies() { - for (const { packageName, subfolderToVendor } of dependencyPaths) { - const sourceDirectoryPath = path.join(directoryOfThisFile, '..', 'node_modules', packageName, subfolderToVendor) - const destinationDirectoryPath = path.join(directoryOfThisFile, '..', 'app', 'vendor', packageName) - await recursiveDirectoryCopy(sourceDirectoryPath, destinationDirectoryPath, undefined, rewriteSourceMapSourcePath.bind(undefined, packageName)) - } - - const indexHtmlPath = path.join(directoryOfThisFile, '..', 'app', 'index.html') - const oldIndexHtml = await fs.readFile(indexHtmlPath, 'utf8') - const importmap = dependencyPaths.reduce((importmap, { packageName, entrypointFile }) => { - importmap.imports[packageName] = `./${path.join('.', 'vendor', packageName, entrypointFile).replace(/\\/g, '/')}` - return importmap - }, { imports: {} as Record }) - const importmapJson = JSON.stringify(importmap, undefined, '\t') - .replace(/^/mg, '\t\t') - const newIndexHtml = oldIndexHtml.replace(/`) - await fs.writeFile(indexHtmlPath, newIndexHtml) -} - -// rewrite the source paths in sourcemap files so they show up in the debugger in a reasonable location and if two source maps refer to the same (relative) path, we end up with them distinguished in the browser debugger -async function rewriteSourceMapSourcePath(packageName: string, sourcePath: string, destinationPath: string) { - const fileExtension = path.extname(sourcePath) - if (fileExtension !== '.map') return - const fileContents = JSON.parse(await fs.readFile(sourcePath, 'utf-8')) as { sources: Array } - for (let i = 0; i < fileContents.sources.length; ++i) { - // we want to ensure all source files show up in the appropriate directory and don't leak out of our directory tree, so we strip leading '../' references - const sourcePath = fileContents.sources[i].replace(/^(?:.\/)*/, '').replace(/^(?:..\/)*/, '') - fileContents.sources[i] = ['dependencies://dependencies', packageName, sourcePath].join('/') - } - await fs.writeFile(destinationPath, JSON.stringify(fileContents)) -} - -vendorDependencies().catch(error => { - console.error(error) - debugger - process.exit(1) -}) diff --git a/package-lock.json b/package-lock.json index 66aa2341..9e877d1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "preact": "10.8.1" }, "devDependencies": { + "@types/node": "18.16.1", + "@zoltu/file-copier": "3.0.0", "typescript": "4.9.3" } }, @@ -68,9 +70,16 @@ } }, "node_modules/@types/node": { - "version": "18.15.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", - "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==" + "version": "18.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.1.tgz", + "integrity": "sha512-DZxSZWXxFfOlx7k7Rv4LAyiMroaxa3Ly/7OOzZO8cBNho0YzAi4qlbrx8W27JGqG57IgR/6J7r+nOJWw6kcvZA==", + "dev": true + }, + "node_modules/@zoltu/file-copier": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@zoltu/file-copier/-/file-copier-3.0.0.tgz", + "integrity": "sha512-foCy+pdL3sHBL0yrv8KCYcHnidii4kbE1xS52xXevyHFBAhRZKeX9qcCfRLIDDeHSYKB94vDeastE7fo3lwQFw==", + "dev": true }, "node_modules/aes-js": { "version": "4.0.0-beta.5", @@ -104,6 +113,11 @@ "node": ">=14.0.0" } }, + "node_modules/ethers/node_modules/@types/node": { + "version": "18.15.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", + "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==" + }, "node_modules/funtypes": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/funtypes/-/funtypes-5.0.3.tgz", diff --git a/package.json b/package.json index 83eed516..320afcf1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "repository": "https://github.com/darkflorist/lunaria", "license": "Unlicense", "devDependencies": { + "@types/node": "18.16.1", + "@zoltu/file-copier": "3.0.0", "typescript": "4.9.3" }, "dependencies": { @@ -13,9 +15,10 @@ "preact": "10.8.1" }, "scripts": { - "build": "rm -rf app/js && tsc", + "build": "tsc", + "prebuild": "rm -rf app/js", "serve": "npx serve -L ./app", - "vendor": "npm ci --ignore-scripts && npm --prefix ./build ci --ignore-scripts && npm --prefix ./build run vendor", - "styles": "npm --prefix ./twcss ci --ignore-scripts && npm --prefix ./twcss run styles" + "styles": "npm --prefix ./twcss run styles", + "vendor": "tsc --project tsconfig.vendor.json && node --enable-source-maps ./build-scripts/vendor.mjs && node --input-type=module -e \"import { promises as fs } from 'fs'; await fs.rm('./build-scripts/vendor.mjs')\"" } } diff --git a/tsconfig.json b/tsconfig.json index 671d1954..ee388446 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "noImplicitThis": true, "jsx": "react-jsx", "jsxImportSource": "preact", - "lib": ["ES2021", "DOM"], + "lib": ["ES2021"], "typeRoots": ["./node_modules/@types"], "types": [] }, diff --git a/tsconfig.vendor.json b/tsconfig.vendor.json new file mode 100644 index 00000000..98b5a473 --- /dev/null +++ b/tsconfig.vendor.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node16", + "rootDir": "./build-scripts", + "inlineSourceMap": true, + "declaration": false, + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "lib": ["ES2020"], + "typeRoots": ["./node_modules/@types"], + "types": ["node"] + }, + "include": ["./build-scripts/*.mts"] +}