diff --git a/.dockerignore b/.dockerignore index 73b07163a..521647cbf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,5 @@ -# Exclude everything, Dockerfile clones the repo anyway +# Exclude everything, Dockerfile clones the repo anyway. +# scripts/ is whitelisted so base.Dockerfile can build AtomVM +# before (independently of) the repo clone. ** +!scripts/** diff --git a/.github/workflows/demo_prod_build_and_deploy.yml b/.github/workflows/demo_prod_build_and_deploy.yml index 68c06bb9a..64186538b 100644 --- a/.github/workflows/demo_prod_build_and_deploy.yml +++ b/.github/workflows/demo_prod_build_and_deploy.yml @@ -9,7 +9,7 @@ env: jobs: build_docker_images: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write @@ -40,6 +40,12 @@ jobs: ${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour ${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-burrito + # Busts the base image's AtomVM layer cache exactly when FissionVM moves, + # so AtomVM isn't rebuilt on every popcorn commit. + - name: Get FissionVM commit + id: fvm + run: echo "sha=$(git ls-remote https://github.com/software-mansion-labs/FissionVM.git swm | cut -f1)" >> $GITHUB_OUTPUT + - name: Build base Docker image id: build-base uses: docker/build-push-action@v6 @@ -48,6 +54,7 @@ jobs: file: ./docker/base.Dockerfile build-args: | COMMIT_REF=${{ github.sha }} + FISSIONVM_SHA=${{ steps.fvm.outputs.sha }} push: true github-token: ${{ secrets.GITHUB_TOKEN }} tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-base:latest" @@ -55,29 +62,32 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Build landing Docker image - id: build-and-push-landing - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/landing.Dockerfile - build-args: | - POPCORN_BASE_TAG=latest - SYNC_THIRD_PARTY_ASSETS=true - pull: true - push: true - github-token: ${{ secrets.GITHUB_TOKEN }} - tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-landing:latest" - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # - name: Build landing Docker image + # id: build-and-push-landing + # uses: docker/build-push-action@v6 + # with: + # context: . + # file: ./docker/landing.Dockerfile + # build-args: | + # POPCORN_BASE_TAG=latest + # SYNC_THIRD_PARTY_ASSETS=true + # pull: true + # push: true + # github-token: ${{ secrets.GITHUB_TOKEN }} + # tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-landing:latest" + # labels: ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max - - name: Build burrito Docker image - id: build-and-push-burrito + # The demos image (kanban + burrito + pong + nginx + Postgres) is pushed + # under the burrito name so the existing server-side popcorn-burrito.yml + # deploys it unchanged. + - name: Build demos Docker image (deployed as burrito) + id: build-and-push-demos uses: docker/build-push-action@v6 with: context: . - file: ./docker/burrito/Dockerfile + file: ./docker/demos/Dockerfile build-args: | POPCORN_BASE_TAG=latest pull: true @@ -88,26 +98,26 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Build language tour Docker image - id: build-and-push-langtour - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/langtour.Dockerfile - # Sentry DSN is embedded in built JS, not really a secret - build-args: | - POPCORN_BASE_TAG=latest - SYNC_THIRD_PARTY_ASSETS=true - SENTRY_DSN=${{ secrets.SENTRY_CLIENT_DSN }} - SENTRY_MODE=production - APP_VERSION=${{ github.sha }} - pull: true - push: true - github-token: ${{ secrets.GITHUB_TOKEN }} - tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour:latest" - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # - name: Build language tour Docker image + # id: build-and-push-langtour + # uses: docker/build-push-action@v6 + # with: + # context: . + # file: ./docker/langtour.Dockerfile + # # Sentry DSN is embedded in built JS, not really a secret + # build-args: | + # POPCORN_BASE_TAG=latest + # SYNC_THIRD_PARTY_ASSETS=true + # SENTRY_DSN=${{ secrets.SENTRY_CLIENT_DSN }} + # SENTRY_MODE=production + # APP_VERSION=${{ github.sha }} + # pull: true + # push: true + # github-token: ${{ secrets.GITHUB_TOKEN }} + # tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour:latest" + # labels: ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max deploy_production: runs-on: ubuntu-latest diff --git a/.github/workflows/demo_staging_build_and_deploy.yml b/.github/workflows/demo_staging_build_and_deploy.yml index 63ea1c228..b8c3b7979 100644 --- a/.github/workflows/demo_staging_build_and_deploy.yml +++ b/.github/workflows/demo_staging_build_and_deploy.yml @@ -18,8 +18,10 @@ on: - "docker/base.Dockerfile" - "docker/landing.Dockerfile" - "docker/langtour.Dockerfile" - - "docker/burrito/Dockerfile" + - "docker/demos/**" + - "examples/local-lv-kanban/**" - "examples/local-lv-burrito/**" + - "examples/local-lv-pong/**" - ".github/workflows/demo_staging_build_and_deploy.yml" - "pnpm-workspace.yaml" - "pnpm-lock.yaml" @@ -33,7 +35,7 @@ env: jobs: build_docker_images: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write @@ -65,6 +67,12 @@ jobs: ${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour ${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-burrito + # Busts the base image's AtomVM layer cache exactly when FissionVM moves, + # so AtomVM isn't rebuilt on every popcorn commit. + - name: Get FissionVM commit + id: fvm + run: echo "sha=$(git ls-remote https://github.com/software-mansion-labs/FissionVM.git swm | cut -f1)" >> $GITHUB_OUTPUT + - name: Build base Docker image id: build-base uses: docker/build-push-action@v6 @@ -73,6 +81,7 @@ jobs: file: ./docker/base.Dockerfile build-args: | COMMIT_REF=${{ github.sha }} + FISSIONVM_SHA=${{ steps.fvm.outputs.sha }} push: true github-token: ${{ secrets.GITHUB_TOKEN }} tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-base:staging" @@ -80,29 +89,32 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Build landing Docker image - id: build-and-push-landing - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/landing.Dockerfile - build-args: | - POPCORN_BASE_TAG=staging - SYNC_THIRD_PARTY_ASSETS=true - pull: true - push: true - github-token: ${{ secrets.GITHUB_TOKEN }} - tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-landing:staging" - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # - name: Build landing Docker image + # id: build-and-push-landing + # uses: docker/build-push-action@v6 + # with: + # context: . + # file: ./docker/landing.Dockerfile + # build-args: | + # POPCORN_BASE_TAG=staging + # SYNC_THIRD_PARTY_ASSETS=true + # pull: true + # push: true + # github-token: ${{ secrets.GITHUB_TOKEN }} + # tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-landing:staging" + # labels: ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max - - name: Build burrito Docker image - id: build-and-push-burrito + # The demos image (kanban + burrito + nginx + Postgres) is pushed under + # the burrito name so the existing server-side popcorn-burrito.yml + # deploys it unchanged. + - name: Build demos Docker image (deployed as burrito) + id: build-and-push-demos uses: docker/build-push-action@v6 with: context: . - file: ./docker/burrito/Dockerfile + file: ./docker/demos/Dockerfile build-args: | POPCORN_BASE_TAG=staging pull: true @@ -113,26 +125,26 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Build language tour Docker image - id: build-and-push-langtour - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/langtour.Dockerfile - # Sentry DSN is embedded in built JS, not really a secret - build-args: | - POPCORN_BASE_TAG=staging - SYNC_THIRD_PARTY_ASSETS=true - SENTRY_DSN=${{ secrets.SENTRY_CLIENT_DSN }} - SENTRY_MODE=staging - APP_VERSION=${{ github.sha }} - pull: true - push: true - github-token: ${{ secrets.GITHUB_TOKEN }} - tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour:staging" - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # - name: Build language tour Docker image + # id: build-and-push-langtour + # uses: docker/build-push-action@v6 + # with: + # context: . + # file: ./docker/langtour.Dockerfile + # # Sentry DSN is embedded in built JS, not really a secret + # build-args: | + # POPCORN_BASE_TAG=staging + # SYNC_THIRD_PARTY_ASSETS=true + # SENTRY_DSN=${{ secrets.SENTRY_CLIENT_DSN }} + # SENTRY_MODE=staging + # APP_VERSION=${{ github.sha }} + # pull: true + # push: true + # github-token: ${{ secrets.GITHUB_TOKEN }} + # tags: "${{ env.REGISTRY }}/${{ env.ORG_NAME }}/popcorn-langtour:staging" + # labels: ${{ steps.meta.outputs.labels }} + # cache-from: type=gha + # cache-to: type=gha,mode=max deploy_staging: runs-on: ubuntu-latest diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 01938fc09..ad13c1d43 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -17,7 +17,7 @@ ARG COMMIT_REF WORKDIR /build/ RUN mkdir -p popcorn out RUN apt-get -y update && \ - apt-get -y install git curl cmake gperf libmbedtls-dev zlib1g-dev \ + apt-get -y install git curl cmake ninja-build gperf libmbedtls-dev zlib1g-dev \ automake make gcc g++ libssl-dev libncurses-dev \ default-jre-headless python3 xz-utils gpg wget @@ -47,6 +47,15 @@ RUN mise install erlang@26.0.2 elixir@1.17.3-otp-26 && \ mise exec erlang@26.0.2 elixir@1.17.3-otp-26 -- mix local.hex --force RUN mise use --global emsdk@"${EMSDK_VERSION}" && mise install +# Build the release AtomVM WASM runtime once, before the repo clone, so the +# layer survives popcorn commits. FISSIONVM_SHA is only a cache key: the deploy +# workflows pass the current FissionVM swm commit so this layer is rebuilt +# exactly when FissionVM moves. +COPY scripts/ /build/popcorn-scripts/ +ARG FISSIONVM_SHA=untracked +RUN echo "FissionVM swm @ ${FISSIONVM_SHA}" && \ + /build/popcorn-scripts/build-atomvm.sh --outdir /build/atomvm-release-wasm release-wasm + RUN git clone https://github.com/software-mansion/popcorn.git /build/popcorn && \ mise trust /build/popcorn RUN cd /build/popcorn && git fetch && git checkout "${COMMIT_REF}" @@ -62,3 +71,11 @@ RUN mise exec erlang@"${ERLANG_VERSION}" elixir@"${ELIXIR_VERSION}" -- \ scripts/build-beam.sh release RUN mise exec erlang@"${ERLANG_VERSION}" elixir@"${ELIXIR_VERSION}" -- \ pnpm -F ./popcorn/js build + +# Build the popcorn-2 JS package against the release AtomVM runtime built +# above. Images that build the demos (docker/demos) link local-live-view to +# this package, so AtomVM is compiled once here instead of once per demo app. +RUN pnpm --dir popcorn-2 install --frozen-lockfile --child-concurrency=1 --network-concurrency=1 +RUN mkdir -p popcorn-2/js/assets && \ + cp /build/atomvm-release-wasm/AtomVM.mjs /build/atomvm-release-wasm/AtomVM.wasm popcorn-2/js/assets/ && \ + pnpm --dir popcorn-2/js exec rollup -c diff --git a/docker/burrito/Dockerfile b/docker/burrito/Dockerfile deleted file mode 100644 index c1b8f8549..000000000 --- a/docker/burrito/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -ARG POPCORN_BASE_IMAGE=ghcr.io/software-mansion/popcorn-base -ARG POPCORN_BASE_TAG=latest - -FROM ${POPCORN_BASE_IMAGE}:${POPCORN_BASE_TAG} AS builder - -COPY popcorn-2/elixir/ /build/popcorn/popcorn-2/elixir/ -COPY local-live-view/ /build/popcorn/local-live-view/ - -WORKDIR /build/popcorn -RUN CI=true pnpm install --no-frozen-lockfile --shamefully-hoist - -WORKDIR /build/popcorn/examples/local-lv-burrito - -COPY examples/local-lv-burrito/mix.exs examples/local-lv-burrito/mix.lock ./ -RUN mix deps.get --only prod -RUN mkdir -p config -COPY examples/local-lv-burrito/config/config.exs examples/local-lv-burrito/config/prod.exs config/ -RUN mix deps.compile - -RUN mix tailwind.install --if-missing -RUN mix esbuild.install --if-missing - -COPY examples/local-lv-burrito/local/ local/ -RUN cd local && mix deps.get && MIX_TARGET=wasm mix popcorn.cook - -RUN mix llv.build - -COPY examples/local-lv-burrito/lib/ lib/ -COPY examples/local-lv-burrito/assets/ assets/ -RUN mix compile -RUN mix esbuild burrito --minify -RUN mix tailwind burrito --minify -RUN mix phx.digest - -COPY examples/local-lv-burrito/config/runtime.exs config/ -RUN mix release - -# ── Runtime stage ───────────────────────────────────────────────────────────── -FROM ubuntu:noble AS final - -RUN apt-get update -y && apt-get install -y --no-install-recommends \ - libstdc++6 openssl libncurses6 locales ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN locale-gen en_US.UTF-8 -ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 - -WORKDIR /app -RUN chown nobody /app - -ENV MIX_ENV="prod" -ENV PHX_SERVER="true" - -COPY --from=builder --chown=nobody:root \ - /build/popcorn/examples/local-lv-burrito/_build/prod/rel/burrito ./ - -USER nobody - -CMD ["/app/bin/burrito", "start"] diff --git a/docker/burrito/Dockerfile.dockerignore b/docker/burrito/Dockerfile.dockerignore deleted file mode 100644 index cf7f2e8fe..000000000 --- a/docker/burrito/Dockerfile.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -# Exclude everything -** -# Re-include only what the Dockerfile COPYs -!popcorn/ -!popcorn-2/elixir/ -!popcorn-2/elixir/** - -!local-live-view/ -!local-live-view/** - -!examples/ -!examples/local-lv-burrito/ -!examples/local-lv-burrito/** diff --git a/docker/demos/Dockerfile b/docker/demos/Dockerfile new file mode 100644 index 000000000..cd5fd2a85 --- /dev/null +++ b/docker/demos/Dockerfile @@ -0,0 +1,141 @@ +ARG POPCORN_BASE_IMAGE=ghcr.io/software-mansion/popcorn-base +ARG POPCORN_BASE_TAG=latest + +# ── Shared demo build stage ─────────────────────────────────────────────────── +FROM ${POPCORN_BASE_IMAGE}:${POPCORN_BASE_TAG} AS demos-base + +COPY popcorn/elixir/ /build/popcorn/popcorn/elixir/ +COPY local-live-view/ /build/popcorn/local-live-view/ + +WORKDIR /build/popcorn + +# Deployed demos run the prod (release-wasm) AtomVM runtime: point +# local-live-view's @swmansion/popcorn at the package built in the base image +# instead of the version published to the npm registry. +RUN node -e 'const fs = require("fs"); const f = "pnpm-workspace.yaml"; fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(/^overrides:$/m, "overrides:\n \"@swmansion/popcorn\": link:./popcorn-2/js"))' +RUN CI=true pnpm install --no-frozen-lockfile --shamefully-hoist + +# ── Kanban builder ──────────────────────────────────────────────────────────── +FROM demos-base AS builder-kanban + +WORKDIR /build/popcorn/examples/local-lv-kanban + +COPY examples/local-lv-kanban/mix.exs examples/local-lv-kanban/mix.lock ./ +RUN mix deps.get --only prod +RUN mkdir -p config +COPY examples/local-lv-kanban/config/config.exs examples/local-lv-kanban/config/prod.exs config/ +RUN mix deps.compile + +RUN mix tailwind.install --if-missing +RUN mix esbuild.install --if-missing + +COPY examples/local-lv-kanban/local/ local/ +RUN cd local && mix deps.get && MIX_TARGET=wasm mix popcorn.cook + +RUN mix llv.build + +COPY examples/local-lv-kanban/lib/ lib/ +COPY examples/local-lv-kanban/assets/ assets/ +COPY examples/local-lv-kanban/priv/ priv/ +RUN mix compile +RUN mix esbuild local_lv_kanban --minify +RUN mix tailwind local_lv_kanban --minify +RUN mix phx.digest + +COPY examples/local-lv-kanban/config/runtime.exs config/ +COPY examples/local-lv-kanban/rel/ rel/ +RUN mix release + +# ── Burrito builder ─────────────────────────────────────────────────────────── +FROM demos-base AS builder-burrito + +WORKDIR /build/popcorn/examples/local-lv-burrito + +COPY examples/local-lv-burrito/mix.exs examples/local-lv-burrito/mix.lock ./ +RUN mix deps.get --only prod +RUN mkdir -p config +COPY examples/local-lv-burrito/config/config.exs examples/local-lv-burrito/config/prod.exs config/ +RUN mix deps.compile + +RUN mix tailwind.install --if-missing +RUN mix esbuild.install --if-missing + +COPY examples/local-lv-burrito/local/ local/ +RUN cd local && mix deps.get && MIX_TARGET=wasm mix popcorn.cook + +RUN mix llv.build + +COPY examples/local-lv-burrito/lib/ lib/ +COPY examples/local-lv-burrito/assets/ assets/ +RUN mix compile +RUN mix esbuild burrito --minify +RUN mix tailwind burrito --minify +RUN mix phx.digest + +COPY examples/local-lv-burrito/config/runtime.exs config/ +RUN mix release + +# ── Pong builder ────────────────────────────────────────────────────────────── +FROM demos-base AS builder-pong + +WORKDIR /build/popcorn/examples/local-lv-pong + +COPY examples/local-lv-pong/mix.exs examples/local-lv-pong/mix.lock ./ +RUN mix deps.get --only prod +RUN mkdir -p config +COPY examples/local-lv-pong/config/config.exs examples/local-lv-pong/config/prod.exs config/ +RUN mix deps.compile + +RUN mix tailwind.install --if-missing +RUN mix esbuild.install --if-missing + +COPY examples/local-lv-pong/local/ local/ +RUN cd local && mix deps.get && MIX_TARGET=wasm mix popcorn.cook + +RUN mix llv.build + +COPY examples/local-lv-pong/lib/ lib/ +COPY examples/local-lv-pong/assets/ assets/ +COPY examples/local-lv-pong/priv/ priv/ +RUN mix compile +RUN mix esbuild local_lv_pong --minify +RUN mix tailwind local_lv_pong --minify +RUN mix phx.digest + +COPY examples/local-lv-pong/config/runtime.exs config/ +RUN mix release + +# ── Runtime stage ───────────────────────────────────────────────────────────── +# Single self-contained container: the demo apps, an embedded Postgres +# (shared; only kanban uses it) and nginx routing /kanban, /burrito and /pong. +# No volume: demo data is disposable and seeds repopulate it on redeploy. +FROM ubuntu:noble AS final + +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + libstdc++6 openssl libncurses6 locales ca-certificates \ + postgresql-16 nginx \ + && rm -rf /var/lib/apt/lists/* + +RUN locale-gen en_US.UTF-8 +ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 + +WORKDIR /app +RUN chown nobody /app + +ENV MIX_ENV="prod" +ENV PHX_SERVER="true" + +COPY --from=builder-kanban --chown=nobody:root \ + /build/popcorn/examples/local-lv-kanban/_build/prod/rel/local_lv_kanban ./kanban/ +COPY --from=builder-burrito --chown=nobody:root \ + /build/popcorn/examples/local-lv-burrito/_build/prod/rel/burrito ./burrito/ +COPY --from=builder-pong --chown=nobody:root \ + /build/popcorn/examples/local-lv-pong/_build/prod/rel/local_lv_pong ./pong/ + +COPY docker/demos/nginx.conf /etc/nginx/nginx.conf.template +COPY docker/demos/index.html /var/www/demos/index.html +COPY --chmod=755 docker/demos/entrypoint.sh /entrypoint.sh + +# Root: the entrypoint starts Postgres and nginx, then drops to nobody for +# the apps. +CMD ["/entrypoint.sh"] diff --git a/docker/demos/Dockerfile.dockerignore b/docker/demos/Dockerfile.dockerignore new file mode 100644 index 000000000..5c66c2ab3 --- /dev/null +++ b/docker/demos/Dockerfile.dockerignore @@ -0,0 +1,15 @@ +** +!docker/demos/entrypoint.sh +!docker/demos/nginx.conf +!docker/demos/index.html +!popcorn/elixir/** +!local-live-view/** +!examples/local-lv-kanban/** +examples/local-lv-kanban/test +!examples/local-lv-burrito/** +!examples/local-lv-pong/** +examples/local-lv-pong/test +**/_build +**/deps +**/node_modules +**/erl_crash.dump diff --git a/docker/demos/entrypoint.sh b/docker/demos/entrypoint.sh new file mode 100644 index 000000000..964280d05 --- /dev/null +++ b/docker/demos/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Boots the embedded Postgres, prepares the kanban DB, starts the Phoenix +# releases (kanban on :4001, burrito on :4002, pong on :4003) and nginx +# routing /kanban, /burrito and /pong on $PORT (default 4000). The DB lives inside the +# container (no volume): staging demo data is disposable and the idempotent +# seeds repopulate it on every (re)deploy. +set -e + +PG_BIN=/usr/lib/postgresql/16/bin +export PGDATA=/var/lib/postgresql/data +NGINX_PORT="${PORT:-4000}" + +mkdir -p "$PGDATA" +chown -R postgres:postgres "$PGDATA" + +if [ ! -s "$PGDATA/PG_VERSION" ]; then + # Plain initdb defaults pg_hba to trust auth; fine here — Postgres listens + # on loopback only, inside the container. + su -s /bin/sh postgres -c "$PG_BIN/initdb -D $PGDATA -E UTF8" +fi + +su -s /bin/sh postgres -c "$PG_BIN/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'" +su -s /bin/sh postgres -c "$PG_BIN/psql -tAc \"SELECT 1 FROM pg_database WHERE datname='kanban'\" | grep -q 1 || $PG_BIN/createdb kanban" + +export DATABASE_URL="${DATABASE_URL:-ecto://postgres@127.0.0.1/kanban}" +export HOME=/app + +# setpriv (not su) so the apps keep SECRET_KEY_BASE/PHX_HOST from the +# container environment. Each app gets its own PORT and URL_PATH. +run_as_nobody() { + setpriv --reuid=nobody --regid=nogroup --clear-groups "$@" +} + +run_as_nobody env PORT=4001 URL_PATH=/kanban /app/kanban/bin/migrate +run_as_nobody env PORT=4001 URL_PATH=/kanban /app/kanban/bin/seed + +run_as_nobody env PORT=4001 URL_PATH=/kanban /app/kanban/bin/server & +run_as_nobody env PORT=4002 URL_PATH=/burrito /app/burrito/bin/burrito start & +run_as_nobody env PORT=4003 URL_PATH=/pong /app/pong/bin/local_lv_pong start & + +sed "s/__PORT__/$NGINX_PORT/" /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf +nginx -g 'daemon off;' & + +# Exit (and let the restart policy recover) as soon as any process dies. +wait -n diff --git a/docker/demos/index.html b/docker/demos/index.html new file mode 100644 index 000000000..1be91ebf8 --- /dev/null +++ b/docker/demos/index.html @@ -0,0 +1,159 @@ + + + + + + Popcorn demos + + + +
+

Local LiveView demos

+

+ These apps are built with + Local LiveView + — Phoenix LiveView running entirely in your browser thanks to + Popcorn. The source code is available + on GitHub. +

+ + +
+ + \ No newline at end of file diff --git a/docker/demos/nginx.conf b/docker/demos/nginx.conf new file mode 100644 index 000000000..76b6d315f --- /dev/null +++ b/docker/demos/nginx.conf @@ -0,0 +1,72 @@ +worker_processes 1; +pid /run/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + access_log off; + default_type text/html; + include /etc/nginx/mime.types; + sendfile on; + + map $http_upgrade $connection_upgrade { + default upgrade; + "" close; + } + + server { + # __PORT__ is substituted by the entrypoint (defaults to 4000). + listen __PORT__; + # Relative redirects: the container port is not the public port/scheme. + absolute_redirect off; + + location = / { + root /var/www/demos; + try_files /index.html =404; + } + + location = /kanban { + return 301 /kanban/; + } + + # Strips the /kanban prefix; the app generates prefixed URLs itself + # via URL_PATH=/kanban. + location /kanban/ { + proxy_pass http://127.0.0.1:4001/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /burrito { + return 301 /burrito/; + } + + location /burrito/ { + proxy_pass http://127.0.0.1:4002/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /pong { + return 301 /pong/; + } + + location /pong/ { + proxy_pass http://127.0.0.1:4003/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } +} diff --git a/examples/local-lv-burrito/assets/js/app.js b/examples/local-lv-burrito/assets/js/app.js index 9c96a0269..44a3c588e 100644 --- a/examples/local-lv-burrito/assets/js/app.js +++ b/examples/local-lv-burrito/assets/js/app.js @@ -330,11 +330,16 @@ window.toggleLatency = function () { // ─── LiveSocket Setup ───────────────────────────────────────────────────────── +// When deployed under a path prefix (URL_PATH, e.g. /burrito behind a +// prefix-stripping proxy) the socket and bundle URLs need that prefix too; +// derive it from this bundle's own URL so dev (no prefix) works unchanged. +const basePath = new URL(import.meta.url).pathname.replace(/\/assets\/.*$/, ""); + const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); -const liveSocket = new LiveSocket("/live", Socket, { +const liveSocket = new LiveSocket(`${basePath}/live`, Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, hooks: { ScrollSync, InfoModal, LLVLoader }, @@ -351,7 +356,8 @@ liveSocket.connect(); window.liveSocket = liveSocket; window.llvEngine = await LLVEngine.create(liveSocket, { - bundlePaths: ["/assets/js/wasm/bundle.avm"], + bundlePaths: [`${basePath}/assets/js/wasm/bundle.avm`], + llvSocketURI: `${basePath}/llv_socket`, }); window.dispatchEvent(new CustomEvent("llv:ready")); diff --git a/examples/local-lv-burrito/config/runtime.exs b/examples/local-lv-burrito/config/runtime.exs index e76e4a5a8..62f3d26cf 100644 --- a/examples/local-lv-burrito/config/runtime.exs +++ b/examples/local-lv-burrito/config/runtime.exs @@ -18,7 +18,9 @@ if config_env() == :prod do config :burrito, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :burrito, BurritoWeb.Endpoint, - url: [host: host, port: 443, scheme: "https"], + # URL_PATH lets the app be served under a path prefix (e.g. /burrito + # behind a reverse proxy that strips the prefix). + url: [host: host, port: 443, scheme: "https", path: System.get_env("URL_PATH", "/")], http: [ ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port diff --git a/examples/local-lv-burrito/mix.exs b/examples/local-lv-burrito/mix.exs index 0a6bc2cb9..f7324585b 100644 --- a/examples/local-lv-burrito/mix.exs +++ b/examples/local-lv-burrito/mix.exs @@ -9,7 +9,6 @@ defmodule Burrito.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), - releases: releases(), deps: deps(), compilers: [:phoenix_live_view] ++ Mix.compilers(), listeners: [Phoenix.CodeReloader] @@ -50,20 +49,11 @@ defmodule Burrito.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, - {:local_live_view, - path: System.get_env("LOCAL_LIVE_VIEW_PATH", "../../local-live-view"), runtime: false}, + {:local_live_view, path: System.get_env("LOCAL_LIVE_VIEW_PATH", "../../local-live-view")}, {:local, path: "local"} ] end - defp releases do - [ - burrito: [ - applications: [local_live_view: :load] - ] - ] - end - defp aliases do [ build: ["setup"], diff --git a/examples/local-lv-kanban/assets/js/app.js b/examples/local-lv-kanban/assets/js/app.js index b8df1c9e3..494939c44 100644 --- a/examples/local-lv-kanban/assets/js/app.js +++ b/examples/local-lv-kanban/assets/js/app.js @@ -25,8 +25,13 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/local_lv_kanban" import topbar from "../vendor/topbar" +// When deployed under a path prefix (URL_PATH, e.g. /kanban behind a +// prefix-stripping proxy) the socket and bundle URLs need that prefix too; +// derive it from this bundle's own URL so dev (no prefix) works unchanged. +const basePath = new URL(import.meta.url).pathname.replace(/\/assets\/.*$/, "") + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -const liveSocket = new LiveSocket("/live", Socket, { +const liveSocket = new LiveSocket(`${basePath}/live`, Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, hooks: {...colocatedHooks}, @@ -41,7 +46,10 @@ window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) liveSocket.connect(); import { LLVEngine } from "local_live_view"; -await LLVEngine.create(liveSocket, { bundlePaths: ["/assets/js/wasm/bundle.avm"] }); +await LLVEngine.create(liveSocket, { + bundlePaths: [`${basePath}/assets/js/wasm/bundle.avm`], + llvSocketURI: `${basePath}/llv_socket`, +}); // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() diff --git a/examples/local-lv-kanban/config/runtime.exs b/examples/local-lv-kanban/config/runtime.exs index 50e54f5bd..3d103999f 100644 --- a/examples/local-lv-kanban/config/runtime.exs +++ b/examples/local-lv-kanban/config/runtime.exs @@ -58,7 +58,9 @@ if config_env() == :prod do config :local_lv_kanban, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :local_lv_kanban, LocalLvKanbanWeb.Endpoint, - url: [host: host, port: 443, scheme: "https"], + # URL_PATH lets the app be served under a path prefix (e.g. /kanban + # behind a reverse proxy that strips the prefix). + url: [host: host, port: 443, scheme: "https", path: System.get_env("URL_PATH", "/")], http: [ # Enable IPv6 and bind on all interfaces. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. diff --git a/examples/local-lv-kanban/lib/local_lv_kanban/release.ex b/examples/local-lv-kanban/lib/local_lv_kanban/release.ex new file mode 100644 index 000000000..60c816496 --- /dev/null +++ b/examples/local-lv-kanban/lib/local_lv_kanban/release.ex @@ -0,0 +1,43 @@ +defmodule LocalLvKanban.Release do + @moduledoc """ + Used for executing DB release tasks when run in production, without Mix + installed. + """ + @app :local_lv_kanban + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + # Not part of the phx.gen.release template: runs priv/repo/seeds.exs, which + # is idempotent, so this is safe on every deploy. + def seed do + load_app() + + for repo <- repos() do + {:ok, _, _} = + Ecto.Migrator.with_repo(repo, fn _repo -> + seeds = Path.join([Application.app_dir(@app, "priv"), "repo", "seeds.exs"]) + if File.exists?(seeds), do: Code.eval_file(seeds) + end) + end + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.ensure_all_started(:ssl) + Application.ensure_loaded(@app) + end +end diff --git a/examples/local-lv-kanban/lib/local_lv_kanban_web/live/boards_live.ex b/examples/local-lv-kanban/lib/local_lv_kanban_web/live/boards_live.ex index 3d101c242..258f6e86d 100644 --- a/examples/local-lv-kanban/lib/local_lv_kanban_web/live/boards_live.ex +++ b/examples/local-lv-kanban/lib/local_lv_kanban_web/live/boards_live.ex @@ -58,7 +58,22 @@ defmodule LocalLvKanbanWeb.BoardsLive do
<.flash kind={:error} flash={@flash} />
-

Kanban boards

+

Kanban boards

+ +

+ Create, browse and edit kanban-style boards. + The board view supports drag & drop, + optimistic updates, all the modals/forms are handled locally, + and it's 100% Elixir. + Create a board, then disconnect from the network and see how it behaves. + It also demonstrates server synchronization via LLV's push_server_event + mechanism - reconnect and open two windows side-by-side to see it in action. + The source code is available + here. +

-
- Press space to play/pause, W/S or ↑/↓ to move -
FPS: {@current_fps} diff --git a/local-live-view/assets/local_live_view/index.ts b/local-live-view/assets/local_live_view/index.ts index ede04133d..d42b7eae6 100644 --- a/local-live-view/assets/local_live_view/index.ts +++ b/local-live-view/assets/local_live_view/index.ts @@ -340,7 +340,7 @@ export class LLVEngine { const Socket = this.socketClass(); const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content"); - const llvSocket = new Socket("/llv_socket", { + const llvSocket = new Socket(this.config.llvSocketURI ?? "/llv_socket", { params: { _csrf_token: csrfToken }, }); llvSocket.connect(); diff --git a/local-live-view/assets/local_live_view/types.ts b/local-live-view/assets/local_live_view/types.ts index 9f400a76a..a3adcebb1 100644 --- a/local-live-view/assets/local_live_view/types.ts +++ b/local-live-view/assets/local_live_view/types.ts @@ -6,6 +6,11 @@ import type { LiveSocketInstanceInterface } from "phoenix_live_view"; export interface LLVConfig { /** Paths to compiled WASM bundle files. Defaults to `["wasm/bundle.avm"]` */ bundlePaths?: string[]; + /** + * The string websocket URI where `LocalLiveView.Socket` is mounted in the + * host endpoint. Defaults to `"/llv_socket"`. + */ + llvSocketURI?: string; /** Enable Popcorn debug logging */ debug?: boolean; /** Callback for raw Popcorn messages */ diff --git a/popcorn-2/elixir/lib/mix/tasks/popcorn.build_runtime.ex b/popcorn-2/elixir/lib/mix/tasks/popcorn.build_runtime.ex index 16e71d77c..456acf7ec 100644 --- a/popcorn-2/elixir/lib/mix/tasks/popcorn.build_runtime.ex +++ b/popcorn-2/elixir/lib/mix/tasks/popcorn.build_runtime.ex @@ -11,6 +11,9 @@ defmodule Mix.Tasks.Popcorn.BuildRuntime do config :popcorn, runtime: {:path, "popcorn_runtime_source/artifacts/", target: target} ``` + The build type follows the Mix env: `prod` builds a release runtime, + every other env builds a debug one. + Options: - `git` - source repo URL (defaults to FissionVM repo) - `git-ref` - branch, tag or commit (only works with the `git` option) @@ -53,8 +56,9 @@ defmodule Mix.Tasks.Popcorn.BuildRuntime do script_args = ["--outdir", artifacts_dir | script_args] - # Add build mode (positional) - always use debug for now - build_mode = "debug-#{target}" + # Add build mode (positional): release runtime in prod, debug in other envs + build_type = if Mix.env() == :prod, do: "release", else: "debug" + build_mode = "#{build_type}-#{target}" script_args = script_args ++ [build_mode] # Find script path relative to the popcorn package root diff --git a/popcorn-2/elixir/patches/popcorn_lib/eavmlib/timer_manager.erl b/popcorn-2/elixir/patches/popcorn_lib/eavmlib/timer_manager.erl index a84d7f071..4411d2d86 100644 --- a/popcorn-2/elixir/patches/popcorn_lib/eavmlib/timer_manager.erl +++ b/popcorn-2/elixir/patches/popcorn_lib/eavmlib/timer_manager.erl @@ -25,7 +25,7 @@ -export([start/0, start_timer/3, cancel_timer/1, get_timer_refs/0, send_after/3, send_interval/3]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). --export([run_timer/5, run_interval/5, send_after_timer/3]). +-export([send_after_timer/3]). -record(state, { timers = [] :: [{reference(), pid()}] @@ -33,6 +33,8 @@ -define(SERVER_NAME, ?MODULE). +-behaviour(gen_server). + -spec start() -> {ok, Pid :: pid()} | {error, Reason :: term()}. start() -> gen_server:start({local, ?SERVER_NAME}, ?MODULE, [], []). @@ -57,11 +59,11 @@ maybe_start() -> TimerRef :: reference(). start_timer(Time, Dest, Msg) -> maybe_start(), - gen_server:call(?SERVER_NAME, {run, run_timer, Time, Dest, Msg}). + gen_server:call(?SERVER_NAME, {run_job, timer, Time, Dest, Msg}). send_interval(Time, Dest, Msg) -> maybe_start(), - TimerRef = gen_server:call(?SERVER_NAME, {run, run_interval, Time, Dest, Msg}), + TimerRef = gen_server:call(?SERVER_NAME, {run_job, interval, Time, Dest, Msg}), {ok, TimerRef}. %%----------------------------------------------------------------------------- @@ -121,8 +123,8 @@ handle_call({cancel, TimerRef}, From, #state{timers = Timers} = State) -> NewTimers = lists:keyreplace(Pid, 2, Timers, {{canceled, From}, Pid}), {noreply, State#state{timers = NewTimers}} end; -handle_call({run, Action, Time, Dest, Msg}, _From, #state{timers = Timers} = State) -> - {TimerRef, Pid} = do_start_timer(Action, Time, Dest, Msg), +handle_call({run_job, Action, Time, Dest, Msg}, _From, #state{timers = Timers} = State) -> + {TimerRef, Pid} = start_job(Action, Time, Dest, Msg), {reply, TimerRef, State#state{timers = [{TimerRef, Pid} | Timers]}}. %% @hidden @@ -163,13 +165,14 @@ terminate(_Reason, _State) -> %% internal functions %% @private -do_start_timer(Action, Time, Dest, Msg) -> +start_job(Action, Time, Dest, Msg) -> TimerRef = erlang:make_ref(), - Pid = spawn(?MODULE, Action, [self(), Time, TimerRef, Dest, Msg]), + MgrPid = self(), + Pid = spawn(fun() -> run_job(Action, MgrPid, Time, TimerRef, Dest, Msg) end), {TimerRef, Pid}. %% @private -run_timer(MgrPid, Time, TimerRef, Dest, Msg) -> +run_job(timer, MgrPid, Time, TimerRef, Dest, Msg) -> Start = erlang:system_time(millisecond), receive {cancel, From} -> @@ -178,10 +181,10 @@ run_timer(MgrPid, Time, TimerRef, Dest, Msg) -> after Time -> MgrPid ! {fired, self()}, Dest ! {timeout, TimerRef, Msg} - end. + end; %% @private -run_interval(MgrPid, Time, _TimerRef, Dest, Msg) -> +run_job(interval, MgrPid, Time, _TimerRef, Dest, Msg) -> erlang:monitor(process, Dest), Start = erlang:system_time(millisecond), do_run_interval(MgrPid, Start, Time, 0, Dest, Msg). diff --git a/popcorn-2/js/package.json b/popcorn-2/js/package.json index 7d9b6f4c7..2ea7e1a8f 100644 --- a/popcorn-2/js/package.json +++ b/popcorn-2/js/package.json @@ -40,8 +40,9 @@ }, "scripts": { "setup": "../../scripts/build-atomvm.sh --outdir assets/ debug-wasm", + "setup:prod": "../../scripts/build-atomvm.sh --outdir assets/ release-wasm", "build": "pnpm run setup && rollup -c", - "build:prod": "pnpm run lint && rm -rf assets dist && ATOMVM_SOURCE='https://github.com/software-mansion-labs/FissionVM.git#swm' pnpm run setup && rollup -c", + "build:prod": "pnpm run lint && rm -rf assets dist && ATOMVM_SOURCE='https://github.com/software-mansion-labs/FissionVM.git#swm' pnpm run setup:prod && rollup -c", "dev": "pnpm run setup && rollup -c --watch", "lint": "tsc --noEmit && eslint && prettier . --check --log-level=warn", "test": "vitest",