├── exercises ├── README.mdx ├── 01.example │ ├── FINISHED.mdx │ ├── README.mdx │ ├── 01.problem.hello │ │ ├── README.mdx │ │ ├── package.json │ │ ├── index.js │ │ └── tsconfig.json │ └── 01.solution.goodbye │ │ ├── README.mdx │ │ ├── package.json │ │ ├── index.js │ │ └── tsconfig.json └── FINISHED.mdx ├── .npmrc ├── epicshop ├── .npmrc ├── update-deps.sh ├── package.json ├── setup-custom.js ├── fly.yaml ├── Dockerfile ├── fix-watch.js ├── setup.js ├── fix.js └── test.js ├── .vscode ├── extensions.json └── settings.json ├── public └── images │ └── instructor.png ├── .prettierignore ├── LICENSE.md ├── tsconfig.json ├── .gitignore ├── eslint.config.js ├── SETUP_STEPS.md ├── package.json ├── .github └── workflows │ └── validate.yml └── README.md /exercises/README.mdx: -------------------------------------------------------------------------------- 1 | # Workshop Title 🎃 2 | -------------------------------------------------------------------------------- /exercises/01.example/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Simple Server 2 | -------------------------------------------------------------------------------- /exercises/01.example/README.mdx: -------------------------------------------------------------------------------- 1 | # Simple Server 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /exercises/01.example/01.problem.hello/README.mdx: -------------------------------------------------------------------------------- 1 | # http.createServer 2 | -------------------------------------------------------------------------------- /exercises/01.example/01.solution.goodbye/README.mdx: -------------------------------------------------------------------------------- 1 | # http.createServer 2 | -------------------------------------------------------------------------------- /epicshop/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /exercises/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Workshop Title 🎃 2 | 3 | Hooray! You're all done! 👏👏 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /public/images/instructor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/workshop-template/HEAD/public/images/instructor.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/.cache/** 3 | **/build/** 4 | **/dist/** 5 | **/public/build/** 6 | **/package-lock.json 7 | **/playwright-report/** 8 | -------------------------------------------------------------------------------- /exercises/01.example/01.problem.hello/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exercises_01.example_01.problem.hello", 3 | "scripts": { 4 | "dev": "echo hi", 5 | "test": "echo 'no tests'" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /exercises/01.example/01.solution.goodbye/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exercises_01.example_01.solution.goodbye", 3 | "scripts": { 4 | "dev": "echo hi", 5 | "test": "echo 'no tests'" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact us 4 | at team@epicweb.dev 5 | -------------------------------------------------------------------------------- /exercises/01.example/01.problem.hello/index.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | 3 | const server = createServer((req, res) => { 4 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 5 | res.end('hello world') 6 | }) 7 | 8 | server.listen(process.env.PORT) 9 | -------------------------------------------------------------------------------- /exercises/01.example/01.solution.goodbye/index.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | 3 | const server = createServer((req, res) => { 4 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 5 | res.end('goodbye world') 6 | }) 7 | 8 | server.listen(process.env.PORT) 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.autoImportFileExcludePatterns": [ 3 | "@remix-run/server-runtime", 4 | "@remix-run/router", 5 | "react-router-dom" 6 | ], 7 | "workbench.editorAssociations": { 8 | "*.db": "sqlite-viewer.view" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "references": [ 7 | { 8 | "path": "exercises/01.example/01.problem.hello" 9 | }, 10 | { 11 | "path": "exercises/01.example/01.solution.goodbye" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | workspace/ 4 | **/.cache/ 5 | **/build/ 6 | **/public/build 7 | **/playwright-report 8 | data.db 9 | /playground 10 | **/tsconfig.tsbuildinfo 11 | **/dist 12 | 13 | # in a real app you'd want to not commit the .env 14 | # file as well, but since this is for a workshop 15 | # we're going to keep them around. 16 | # .env 17 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import defaultConfig from '@epic-web/config/eslint' 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | export default [ 5 | ...defaultConfig, 6 | { 7 | rules: { 8 | // we leave unused vars around for the exercises 9 | 'no-unused-vars': 'off', 10 | '@typescript-eslint/no-unused-vars': 'off', 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /epicshop/update-deps.sh: -------------------------------------------------------------------------------- 1 | npx npm-check-updates --dep prod,dev --upgrade --workspaces --root 2 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root 3 | cd .. 4 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules 5 | npm install 6 | npm run setup 7 | npm run typecheck 8 | npm run lint -- --fix 9 | -------------------------------------------------------------------------------- /epicshop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@epic-web/workshop-app": "^6.45.7", 5 | "@epic-web/workshop-utils": "^6.45.7", 6 | "chokidar": "^4.0.3", 7 | "enquirer": "^2.4.1", 8 | "epicshop": "^6.45.7", 9 | "execa": "^9.6.0", 10 | "fs-extra": "^11.3.2", 11 | "match-sorter": "^8.1.0", 12 | "p-limit": "^7.2.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /exercises/01.example/01.problem.hello/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /exercises/01.example/01.solution.goodbye/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /epicshop/setup-custom.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { 3 | getApps, 4 | isProblemApp, 5 | setPlayground, 6 | } from '@epic-web/workshop-utils/apps.server' 7 | import { warm } from 'epicshop/warm' 8 | import fsExtra from 'fs-extra' 9 | 10 | await warm() 11 | 12 | const allApps = await getApps() 13 | const problemApps = allApps.filter(isProblemApp) 14 | 15 | if (!process.env.SKIP_PLAYGROUND) { 16 | const firstProblemApp = problemApps[0] 17 | if (firstProblemApp) { 18 | console.log('🛝 setting up the first problem app...') 19 | const playgroundPath = path.join(process.cwd(), 'playground') 20 | if (await fsExtra.exists(playgroundPath)) { 21 | console.log('🗑 deleting existing playground app') 22 | await fsExtra.remove(playgroundPath) 23 | } 24 | await setPlayground(firstProblemApp.fullPath).then( 25 | () => { 26 | console.log('✅ first problem app set up') 27 | }, 28 | (error) => { 29 | console.error(error) 30 | throw new Error('❌ first problem app setup failed') 31 | }, 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /epicshop/fly.yaml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 2 | # 3 | 4 | app: 'epicweb-dev-workshop-template' 5 | primary_region: sjc 6 | kill_signal: SIGINT 7 | kill_timeout: 5s 8 | swap_size_mb: 512 9 | 10 | experimental: 11 | auto_rollback: true 12 | 13 | attached: 14 | secrets: {} 15 | 16 | services: 17 | - processes: 18 | - app 19 | protocol: tcp 20 | internal_port: 8080 21 | 22 | ports: 23 | - port: 80 24 | 25 | handlers: 26 | - http 27 | force_https: true 28 | - port: 443 29 | 30 | handlers: 31 | - tls 32 | - http 33 | 34 | concurrency: 35 | type: connections 36 | hard_limit: 100 37 | soft_limit: 80 38 | 39 | tcp_checks: 40 | - interval: 15s 41 | timeout: 2s 42 | grace_period: 1s 43 | 44 | http_checks: 45 | - interval: 10s 46 | timeout: 2s 47 | grace_period: 5s 48 | method: get 49 | path: /resources/healthcheck 50 | protocol: http 51 | tls_skip_verify: false 52 | -------------------------------------------------------------------------------- /epicshop/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-bookworm-slim as base 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | # Build argument for GitHub repo URL 6 | ARG EPICSHOP_GITHUB_REPO 7 | ENV EPICSHOP_GITHUB_REPO=${EPICSHOP_GITHUB_REPO} 8 | 9 | ENV EPICSHOP_CONTEXT_CWD="/myapp/workshop-content" 10 | ENV EPICSHOP_HOME_DIR="/myapp/.epicshop" 11 | ENV EPICSHOP_DEPLOYED="true" 12 | ENV EPICSHOP_DISABLE_WATCHER="true" 13 | ENV FLY="true" 14 | ENV PORT="8080" 15 | ENV NODE_ENV="production" 16 | 17 | # Build argument for commit SHA to bust cache when repo changes 18 | ARG EPICSHOP_COMMIT_SHA 19 | ENV EPICSHOP_COMMIT_SHA=${EPICSHOP_COMMIT_SHA} 20 | 21 | WORKDIR /myapp 22 | 23 | # Clone the workshop repo during build time, excluding database files 24 | # Clone specific commit to keep image small while ensuring cache busting 25 | RUN git init ${EPICSHOP_CONTEXT_CWD} && \ 26 | cd ${EPICSHOP_CONTEXT_CWD} && \ 27 | git remote add origin ${EPICSHOP_GITHUB_REPO} && \ 28 | git fetch --depth 1 origin ${EPICSHOP_COMMIT_SHA} && \ 29 | git checkout ${EPICSHOP_COMMIT_SHA} 30 | 31 | ADD . . 32 | 33 | RUN npm install --omit=dev 34 | 35 | RUN cd ${EPICSHOP_CONTEXT_CWD} && \ 36 | npx epicshop warm 37 | 38 | CMD cd ${EPICSHOP_CONTEXT_CWD} && \ 39 | npx epicshop start 40 | -------------------------------------------------------------------------------- /SETUP_STEPS.md: -------------------------------------------------------------------------------- 1 | # Setup Steps 2 | 3 | 1. Download 4 | ```sh 5 | npx degit epicweb-dev/workshop-template YOUR_REPO_NAME 6 | ``` 7 | 1. Find/replace github repo name from "workshop-template" to "your-repo-name" 8 | (the org is already set to "epicweb-dev") 9 | 1. Find/replace the title of the workshop from "Workshop Title 🎃" to "Your 10 | Workshop Title 🦊" (emoji optional, but fun, choose something you think 11 | represents your topic in some clever way). 12 | 1. Find/replace the subtitle of the workshop from "Workshop subtitle" to "Your 13 | subtitle" (this will appear on the top of the README as well as on the 14 | `og:image` when shared on social media) 15 | 1. Update the workshop summary in the `README.md` 16 | 1. Update the Prerequisites, pre-workshop resources, and if necessary system 17 | requirements in the `README.md` 18 | 1. Update `epicshop.instructor` and `author` properties in `package.json` 19 | 1. Update the `public/images/instructor.png` with your own image 20 | 1. Update all dependencies 21 | ```sh 22 | ./epicshop/update-deps.sh 23 | ``` 24 | 1. Ask Kent to create a Fly app for your workshop to deploy to. Just tell him 25 | what it's called in the `epicshop/fly.toml`. No need to wait on Kent for this 26 | step, just let him know and continue to the next step, he'll get to it soon. 27 | 1. Initialize git and create a github repo on `epicweb-dev` with the name you 28 | chose 29 | 30 | And as the last step, delete this file, create your initial commit, push it, and 31 | start working on your exercises! 32 | -------------------------------------------------------------------------------- /epicshop/fix-watch.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import chokidar from 'chokidar' 4 | import { $ } from 'execa' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const here = (...p) => path.join(__dirname, ...p) 8 | 9 | const workshopRoot = here('..') 10 | 11 | // Watch the exercises directory 12 | const watcher = chokidar.watch(path.join(workshopRoot, 'exercises'), { 13 | ignored: [ 14 | /(^|[\/\\])\../, // ignore dotfiles 15 | (path) => { 16 | // Only watch directories up to depth 2 17 | const relativePath = path.slice(workshopRoot.length + 1) 18 | return relativePath.split('/').length > 4 19 | }, 20 | ], 21 | persistent: true, 22 | ignoreInitial: true, 23 | }) 24 | 25 | const debouncedRun = debounce(run, 200) 26 | 27 | // Add event listeners. 28 | watcher 29 | .on('addDir', (path) => { 30 | debouncedRun() 31 | }) 32 | .on('unlinkDir', (path) => { 33 | debouncedRun() 34 | }) 35 | .on('error', (error) => console.log(`Watcher error: ${error}`)) 36 | 37 | /** 38 | * Simple debounce implementation 39 | */ 40 | function debounce(fn, delay) { 41 | let timer = null 42 | return (...args) => { 43 | if (timer) clearTimeout(timer) 44 | timer = setTimeout(() => { 45 | fn(...args) 46 | }, delay) 47 | } 48 | } 49 | 50 | let running = false 51 | 52 | async function run() { 53 | if (running) { 54 | console.log('still running...') 55 | return 56 | } 57 | running = true 58 | try { 59 | await $({ 60 | stdio: 'inherit', 61 | cwd: workshopRoot, 62 | })`node ./epicshop/fix.js` 63 | } catch (error) { 64 | throw error 65 | } finally { 66 | running = false 67 | } 68 | } 69 | 70 | console.log('Watching exercises directory for changes...') 71 | console.log('running fix to start...') 72 | run() 73 | -------------------------------------------------------------------------------- /epicshop/setup.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process' 2 | 3 | const styles = { 4 | // got these from playing around with what I found from: 5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 6 | // they're the best I could find that works well for light or dark terminals 7 | success: { open: '\u001b[32;1m', close: '\u001b[0m' }, 8 | danger: { open: '\u001b[31;1m', close: '\u001b[0m' }, 9 | info: { open: '\u001b[36;1m', close: '\u001b[0m' }, 10 | subtitle: { open: '\u001b[2;1m', close: '\u001b[0m' }, 11 | } 12 | 13 | function color(modifier, string) { 14 | return styles[modifier].open + string + styles[modifier].close 15 | } 16 | 17 | console.log(color('info', '▶️ Starting workshop setup...')) 18 | 19 | const output = spawnSync('npm --version', { shell: true }) 20 | .stdout.toString() 21 | .trim() 22 | const outputParts = output.split('.') 23 | const major = Number(outputParts[0]) 24 | const minor = Number(outputParts[1]) 25 | if (major < 8 || (major === 8 && minor < 16)) { 26 | console.error( 27 | color( 28 | 'danger', 29 | '🚨 npm version is ' + 30 | output + 31 | ' which is out of date. Please install npm@8.16.0 or greater', 32 | ), 33 | ) 34 | throw new Error('npm version is out of date') 35 | } 36 | 37 | const command = 38 | 'npx --yes "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 39 | console.log( 40 | color('subtitle', ' Running the following command: ' + command), 41 | ) 42 | 43 | const result = spawnSync(command, { stdio: 'inherit', shell: true }) 44 | 45 | if (result.status === 0) { 46 | console.log(color('success', '✅ Workshop setup complete...')) 47 | } else { 48 | process.exit(result.status) 49 | } 50 | 51 | /* 52 | eslint 53 | "no-undef": "off", 54 | "vars-on-top": "off", 55 | */ 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workshop-template", 3 | "private": true, 4 | "epicshop": { 5 | "title": "Workshop Title 🎃", 6 | "subtitle": "Workshop subtitle", 7 | "githubRepo": "https://github.com/epicweb-dev/workshop-template", 8 | "subdomain": "workshop-template", 9 | "instructor": { 10 | "name": "Some instructor", 11 | "avatar": "/images/instructor.png", 12 | "𝕏": "your_x_handle_here" 13 | }, 14 | "product": { 15 | "host": "www.epicweb.dev", 16 | "displayName": "EpicWeb.dev", 17 | "displayNameShort": "Epic Web" 18 | } 19 | }, 20 | "type": "module", 21 | "scripts": { 22 | "postinstall": "cd ./epicshop && npm install", 23 | "start": "npx --prefix ./epicshop epicshop start", 24 | "dev": "npx --prefix ./epicshop epicshop start", 25 | "test": "npm run test --silent --prefix playground", 26 | "test:e2e": "npm run test:e2e --silent --prefix playground", 27 | "test:e2e:dev": "npm run test:e2e:dev --silent --prefix playground", 28 | "test:e2e:run": "npm run test:e2e:run --silent --prefix playground", 29 | "setup": "node ./epicshop/setup.js", 30 | "setup:custom": "node ./epicshop/setup-custom.js", 31 | "lint": "eslint . --concurrency=auto", 32 | "format": "prettier --write .", 33 | "typecheck": "tsc -b", 34 | "validate:all": "npm-run-all --parallel --print-label --print-name --continue-on-error test:all lint typecheck" 35 | }, 36 | "keywords": [], 37 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 38 | "license": "GPL-3.0-only", 39 | "workspaces": [ 40 | "exercises/*/*", 41 | "examples/*" 42 | ], 43 | "engines": { 44 | "node": "^18.19.0 || >=20.5.0", 45 | "npm": ">=8.16.0", 46 | "git": ">=2.18.0" 47 | }, 48 | "devDependencies": { 49 | "@epic-web/config": "^1.21.3", 50 | "eslint": "^9.39.1", 51 | "prettier": "^3.6.2", 52 | "typescript": "^5.9.3" 53 | }, 54 | "prettier": "@epic-web/config/prettier" 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | pull_request: {} 12 | 13 | jobs: 14 | setup: 15 | name: 🔧 Setup 16 | timeout-minutes: 10 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - name: ⬇️ Checkout repo 23 | uses: actions/checkout@v4 24 | 25 | - name: ⎔ Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 24 29 | 30 | - name: ▶️ Run setup script 31 | run: npm run setup 32 | 33 | - name: ʦ TypeScript 34 | run: npm run typecheck 35 | 36 | - name: ⬣ ESLint 37 | run: npm run lint 38 | 39 | tests: 40 | name: 🧪 Test 41 | timeout-minutes: 10 42 | runs-on: ubuntu-latest 43 | # Use continue-on-error to ensure this job doesn't fail the workflow 44 | continue-on-error: true 45 | 46 | steps: 47 | - name: ⬇️ Checkout repo 48 | uses: actions/checkout@v4 49 | 50 | - name: ⎔ Setup node 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: 24 54 | 55 | - name: 📦 Install dependencies 56 | run: npm ci 57 | 58 | - name: 🧪 Run tests 59 | id: run_tests 60 | run: node ./epicshop/test.js ..s 61 | 62 | deploy: 63 | name: 🚀 Deploy 64 | timeout-minutes: 10 65 | runs-on: ubuntu-latest 66 | # only deploy main branch on pushes on non-forks 67 | if: 68 | ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && 69 | github.repository_owner == 'epicweb-dev' }} 70 | 71 | steps: 72 | - name: ⬇️ Checkout repo 73 | uses: actions/checkout@v4 74 | 75 | - name: 🎈 Setup Fly 76 | uses: superfly/flyctl-actions/setup-flyctl@1.5 77 | 78 | - name: 🚀 Deploy 79 | run: 80 | flyctl deploy --remote-only --build-arg 81 | EPICSHOP_GITHUB_REPO=https://github.com/${{ github.repository }} 82 | --build-arg EPICSHOP_COMMIT_SHA=${{ github.sha }} 83 | working-directory: ./epicshop 84 | env: 85 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Workshop Title 🎃

3 | 4 | Workshop subtitle 5 | 6 |

7 | Workshop summary 8 |

9 |
10 | 11 |
12 | 13 |
14 | 18 | 22 | 23 |
24 | 25 |
26 | 27 | 28 | [![Build Status][build-badge]][build] 29 | [![GPL 3.0 License][license-badge]][license] 30 | [![Code of Conduct][coc-badge]][coc] 31 | 32 | 33 | ## Prerequisites 34 | 35 | - TODO: add prerequisites 36 | - Some 37 | - Pre-requisite 38 | - links 39 | - here 40 | 41 | ## Pre-workshop Resources 42 | 43 | Here are some resources you can read before taking the workshop to get you up to 44 | speed on some of the tools and concepts we'll be covering: 45 | 46 | - TODO: add resources 47 | 48 | ## System Requirements 49 | 50 | - [git][git] v2.18 or greater 51 | - [NodeJS][node] v18 or greater 52 | - [npm][npm] v8 or greater 53 | 54 | All of these must be available in your `PATH`. To verify things are set up 55 | properly, you can run this: 56 | 57 | ```shell 58 | git --version 59 | node --version 60 | npm --version 61 | ``` 62 | 63 | If you have trouble with any of these, learn more about the PATH environment 64 | variable and how to fix it here for [windows][win-path] or 65 | [mac/linux][mac-path]. 66 | 67 | ## Setup 68 | 69 | Use the Epic Workshop CLI to get this setup: 70 | 71 | ```sh nonumber 72 | npx --yes epicshop@latest add workshop-template 73 | ``` 74 | 75 | If you experience errors here, please open [an issue][issue] with as many 76 | details as you can offer. 77 | 78 | ## The Workshop App 79 | 80 | Learn all about the workshop app on the 81 | [Epic Web Getting Started Guide](https://www.epicweb.dev/get-started). 82 | 83 | [![Kent with the workshop app in the background](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/280407082-0e012138-e01d-45d5-abf2-86ffe5d03c69.png)](https://www.epicweb.dev/get-started) 84 | 85 | 86 | [npm]: https://www.npmjs.com/ 87 | [node]: https://nodejs.org 88 | [git]: https://git-scm.com/ 89 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/workshop-template/validate.yml?branch=main&logo=github&style=flat-square 90 | [build]: https://github.com/epicweb-dev/workshop-template/actions?query=workflow%3Avalidate 91 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 92 | [license]: https://github.com/epicweb-dev/workshop-template/blob/main/LICENSE 93 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 94 | [coc]: https://kentcdodds.com/conduct 95 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 96 | [mac-path]: http://stackoverflow.com/a/24322978/971592 97 | [issue]: https://github.com/epicweb-dev/workshop-template/issues/new 98 | 99 | -------------------------------------------------------------------------------- /epicshop/fix.js: -------------------------------------------------------------------------------- 1 | // This should run by node without any dependencies 2 | // because you may need to run it without deps. 3 | 4 | import cp from 'node:child_process' 5 | import fs from 'node:fs' 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | const here = (...p) => path.join(__dirname, ...p) 11 | const VERBOSE = false 12 | const logVerbose = (...args) => (VERBOSE ? console.log(...args) : undefined) 13 | 14 | const workshopRoot = here('..') 15 | const examples = (await readDir(here('../examples'))).map(dir => 16 | here(`../examples/${dir}`), 17 | ) 18 | const exercises = (await readDir(here('../exercises'))) 19 | .map(name => here(`../exercises/${name}`)) 20 | .filter(filepath => fs.statSync(filepath).isDirectory()) 21 | const exerciseApps = ( 22 | await Promise.all( 23 | exercises.flatMap(async exercise => { 24 | return (await readDir(exercise)) 25 | .filter(dir => { 26 | return /(problem|solution)/.test(dir) 27 | }) 28 | .map(dir => path.join(exercise, dir)) 29 | }), 30 | ) 31 | ).flat() 32 | const exampleApps = (await readDir(here('../examples'))).map(dir => 33 | here(`../examples/${dir}`), 34 | ) 35 | const apps = [...exampleApps, ...exerciseApps] 36 | 37 | const appsWithPkgJson = [...examples, ...apps].filter(app => { 38 | const pkgjsonPath = path.join(app, 'package.json') 39 | return exists(pkgjsonPath) 40 | }) 41 | 42 | // update the package.json file name property 43 | // to match the parent directory name + directory name 44 | // e.g. exercises/01-goo/problem.01-great 45 | // name: "exercises__sep__01-goo.problem__sep__01-great" 46 | 47 | function relativeToWorkshopRoot(dir) { 48 | return dir.replace(`${workshopRoot}${path.sep}`, '') 49 | } 50 | 51 | await updatePkgNames() 52 | await updateTsconfig() 53 | 54 | async function updatePkgNames() { 55 | for (const file of appsWithPkgJson) { 56 | const pkgjsonPath = path.join(file, 'package.json') 57 | const pkg = JSON.parse(await fs.promises.readFile(pkgjsonPath, 'utf8')) 58 | pkg.name = relativeToWorkshopRoot(file).replace(/\\|\//g, '_') 59 | const written = await writeIfNeeded( 60 | pkgjsonPath, 61 | `${JSON.stringify(pkg, null, 2)}\n`, 62 | ) 63 | if (written) { 64 | console.log(`updated ${path.relative(process.cwd(), pkgjsonPath)}`) 65 | } 66 | } 67 | } 68 | 69 | async function updateTsconfig() { 70 | const tsconfig = { 71 | files: [], 72 | exclude: ['node_modules'], 73 | references: appsWithPkgJson.map(a => ({ 74 | path: relativeToWorkshopRoot(a).replace(/\\/g, '/'), 75 | })), 76 | } 77 | const written = await writeIfNeeded( 78 | path.join(workshopRoot, 'tsconfig.json'), 79 | `${JSON.stringify(tsconfig, null, 2)}\n`, 80 | { parser: 'json' }, 81 | ) 82 | 83 | if (written) { 84 | // delete node_modules/.cache 85 | const cacheDir = path.join(workshopRoot, 'node_modules', '.cache') 86 | if (exists(cacheDir)) { 87 | await fs.promises.rm(cacheDir, { recursive: true }) 88 | } 89 | console.log('all fixed up') 90 | } 91 | } 92 | 93 | async function writeIfNeeded(filepath, content) { 94 | const oldContent = await fs.promises.readFile(filepath, 'utf8') 95 | if (oldContent !== content) { 96 | await fs.promises.writeFile(filepath, content) 97 | } 98 | return oldContent !== content 99 | } 100 | 101 | function exists(p) { 102 | if (!p) return false 103 | try { 104 | fs.statSync(p) 105 | return true 106 | } catch (error) { 107 | return false 108 | } 109 | } 110 | 111 | async function readDir(dir) { 112 | if (exists(dir)) { 113 | return fs.promises.readdir(dir) 114 | } 115 | return [] 116 | } 117 | -------------------------------------------------------------------------------- /epicshop/test.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { performance } from 'perf_hooks' 3 | import { fileURLToPath } from 'url' 4 | import { 5 | getApps, 6 | getAppDisplayName, 7 | } from '@epic-web/workshop-utils/apps.server' 8 | import enquirer from 'enquirer' 9 | import { execa } from 'execa' 10 | import { matchSorter } from 'match-sorter' 11 | import pLimit from 'p-limit' 12 | 13 | const { prompt } = enquirer 14 | 15 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 16 | 17 | function captureOutput() { 18 | const output = [] 19 | return { 20 | write: (chunk, streamType) => { 21 | output.push({ chunk: chunk.toString(), streamType }) 22 | }, 23 | replay: () => { 24 | for (const { chunk, streamType } of output) { 25 | if (streamType === 'stderr') { 26 | process.stderr.write(chunk) 27 | } else { 28 | process.stdout.write(chunk) 29 | } 30 | } 31 | }, 32 | hasOutput: () => output.length > 0, 33 | } 34 | } 35 | 36 | function printTestSummary(results) { 37 | const label = '--- Test Summary ---' 38 | console.log(`\n${label}`) 39 | for (const [appPath, { result, duration }] of results) { 40 | let emoji 41 | switch (result) { 42 | case 'Passed': 43 | emoji = '✅' 44 | break 45 | case 'Failed': 46 | emoji = '❌' 47 | break 48 | case 'Error': 49 | emoji = '💥' 50 | break 51 | case 'Incomplete': 52 | emoji = '⏳' 53 | break 54 | default: 55 | emoji = '❓' 56 | } 57 | console.log(`${emoji} ${appPath} (${duration.toFixed(2)}s)`) 58 | } 59 | console.log(`${'-'.repeat(label.length)}\n`) 60 | } 61 | 62 | async function main() { 63 | const allApps = await getApps() 64 | const allAppsWithTests = allApps.filter((app) => app.test?.type === 'script') 65 | 66 | if (allAppsWithTests.length === 0) { 67 | console.error( 68 | '❌ No apps with tests were found. Ensure your apps have a test script defined in the package.json. Exiting.', 69 | ) 70 | process.exit(1) 71 | } 72 | 73 | let selectedApps 74 | let additionalArgs = [] 75 | 76 | // Parse command-line arguments 77 | const argIndex = process.argv.indexOf('--') 78 | if (argIndex !== -1) { 79 | additionalArgs = process.argv.slice(argIndex + 1) 80 | process.argv = process.argv.slice(0, argIndex) 81 | } 82 | 83 | if (process.argv[2]) { 84 | const patterns = process.argv[2].toLowerCase().split(',') 85 | selectedApps = allAppsWithTests.filter((app) => { 86 | const { exerciseNumber, stepNumber, type } = app 87 | 88 | return patterns.some((pattern) => { 89 | let [patternExercise = '*', patternStep = '*', patternType = '*'] = 90 | pattern.split('.') 91 | 92 | patternExercise ||= '*' 93 | patternStep ||= '*' 94 | patternType ||= '*' 95 | 96 | return ( 97 | (patternExercise === '*' || 98 | exerciseNumber === Number(patternExercise)) && 99 | (patternStep === '*' || stepNumber === Number(patternStep)) && 100 | (patternType === '*' || type.includes(patternType)) 101 | ) 102 | }) 103 | }) 104 | } else { 105 | const displayNameMap = new Map( 106 | allAppsWithTests.map((app) => [ 107 | getAppDisplayName(app, allAppsWithTests), 108 | app, 109 | ]), 110 | ) 111 | const choices = displayNameMap.keys() 112 | 113 | const response = await prompt({ 114 | type: 'autocomplete', 115 | name: 'appDisplayNames', 116 | message: 'Select apps to test:', 117 | choices: ['All', ...choices], 118 | multiple: true, 119 | suggest: (input, choices) => { 120 | return matchSorter(choices, input, { keys: ['name'] }) 121 | }, 122 | }) 123 | 124 | selectedApps = response.appDisplayNames.includes('All') 125 | ? allAppsWithTests 126 | : response.appDisplayNames.map((appDisplayName) => 127 | displayNameMap.get(appDisplayName), 128 | ) 129 | 130 | // Update this block to use process.argv 131 | const appPattern = 132 | selectedApps.length === allAppsWithTests.length 133 | ? '*' 134 | : selectedApps 135 | .map((app) => `${app.exerciseNumber}.${app.stepNumber}.${app.type}`) 136 | .join(',') 137 | const additionalArgsString = 138 | additionalArgs.length > 0 ? ` -- ${additionalArgs.join(' ')}` : '' 139 | console.log(`\nℹ️ To skip the prompt next time, use this command:`) 140 | console.log(`npm test -- ${appPattern}${additionalArgsString}\n`) 141 | } 142 | 143 | if (selectedApps.length === 0) { 144 | console.log('⚠️ No apps selected. Exiting.') 145 | return 146 | } 147 | 148 | if (selectedApps.length === 1) { 149 | const app = selectedApps[0] 150 | console.log(`🚀 Running tests for ${app.relativePath}\n\n`) 151 | const startTime = performance.now() 152 | try { 153 | await execa('npm', ['run', 'test', '--silent', '--', ...additionalArgs], { 154 | cwd: app.fullPath, 155 | stdio: 'inherit', 156 | env: { 157 | ...process.env, 158 | PORT: app.dev.portNumber, 159 | }, 160 | }) 161 | const duration = (performance.now() - startTime) / 1000 162 | console.log( 163 | `✅ Finished tests for ${app.relativePath} (${duration.toFixed(2)}s)`, 164 | ) 165 | } catch { 166 | const duration = (performance.now() - startTime) / 1000 167 | console.error( 168 | `❌ Tests failed for ${app.relativePath} (${duration.toFixed(2)}s)`, 169 | ) 170 | process.exit(1) 171 | } 172 | } else { 173 | const limit = pLimit(1) 174 | let hasFailures = false 175 | const runningProcesses = new Map() 176 | let isShuttingDown = false 177 | const results = new Map() 178 | 179 | const shutdownHandler = () => { 180 | if (isShuttingDown) return 181 | isShuttingDown = true 182 | console.log('\nGracefully shutting down. Please wait...') 183 | console.log('Outputting results of running tests:') 184 | for (const [app, output] of runningProcesses.entries()) { 185 | if (output.hasOutput()) { 186 | console.log(`\nPartial results for ${app.relativePath}:\n\n`) 187 | output.replay() 188 | console.log('\n\n') 189 | } else { 190 | console.log(`ℹ️ No output captured for ${app.relativePath}`) 191 | } 192 | // Set result for incomplete tests 193 | if (!results.has(app.relativePath)) { 194 | results.set(app.relativePath, 'Incomplete') 195 | } 196 | } 197 | printTestSummary(results) 198 | // Allow some time for output to be written before exiting 199 | setTimeout(() => process.exit(1), 100) 200 | } 201 | 202 | process.on('SIGINT', shutdownHandler) 203 | process.on('SIGTERM', shutdownHandler) 204 | 205 | const tasks = selectedApps.map((app) => 206 | limit(async () => { 207 | if (isShuttingDown) return 208 | console.log(`🚀 Starting tests for ${app.relativePath}`) 209 | const output = captureOutput() 210 | runningProcesses.set(app, output) 211 | const startTime = performance.now() 212 | try { 213 | const subprocess = execa( 214 | 'npm', 215 | ['run', 'test', '--silent', '--', ...additionalArgs], 216 | { 217 | cwd: path.join(__dirname, '..', app.relativePath), 218 | reject: false, 219 | env: { 220 | ...process.env, 221 | PORT: app.dev.portNumber, 222 | }, 223 | }, 224 | ) 225 | 226 | subprocess.stdout.on('data', (chunk) => output.write(chunk, 'stdout')) 227 | subprocess.stderr.on('data', (chunk) => output.write(chunk, 'stderr')) 228 | 229 | const { exitCode } = await subprocess 230 | const duration = (performance.now() - startTime) / 1000 231 | 232 | runningProcesses.delete(app) 233 | 234 | if (exitCode !== 0) { 235 | hasFailures = true 236 | console.error( 237 | `\n❌ Tests failed for ${app.relativePath} (${duration.toFixed(2)}s):\n\n`, 238 | ) 239 | output.replay() 240 | console.log('\n\n') 241 | results.set(app.relativePath, { result: 'Failed', duration }) 242 | // Set result for incomplete tests 243 | if (!results.has(app.relativePath)) { 244 | results.set(app.relativePath, 'Incomplete') 245 | } 246 | } else { 247 | console.log( 248 | `✅ Finished tests for ${app.relativePath} (${duration.toFixed(2)}s)`, 249 | ) 250 | results.set(app.relativePath, { result: 'Passed', duration }) 251 | } 252 | } catch (error) { 253 | const duration = (performance.now() - startTime) / 1000 254 | runningProcesses.delete(app) 255 | hasFailures = true 256 | console.error( 257 | `\n❌ An error occurred while running tests for ${app.relativePath} (${duration.toFixed(2)}s):\n\n`, 258 | ) 259 | console.error(error.message) 260 | output.replay() 261 | console.log('\n\n') 262 | results.set(app.relativePath, { result: 'Error', duration }) 263 | } 264 | }), 265 | ) 266 | 267 | await Promise.all(tasks) 268 | 269 | // Print summary output 270 | printTestSummary(results) 271 | 272 | if (hasFailures) { 273 | process.exit(1) 274 | } 275 | } 276 | } 277 | 278 | main().catch((error) => { 279 | if (error) { 280 | console.error('❌ An error occurred:', error) 281 | } 282 | setTimeout(() => process.exit(1), 100) 283 | }) 284 | --------------------------------------------------------------------------------