Slide 1

Slide 1 text

RemixͰVersion skewʹཱͪ޲͔͏ Remix Tokyo Meetup #2 2024.09.25

Slide 2

Slide 2 text

contents - Version skewͱ͸ - ΫϥΠΞϯτͰରԠ͢Δ - αʔόͰରԠ͢Δ - ·ͱΊ 2

Slide 3

Slide 3 text

Version skewͱ͸ 1 3

Slide 4

Slide 4 text

“ Version skewͱ͸ͦͷ··ͷ௨Γ
 όʔδϣϯͷࠩҟʹΑͬͯ
 Ҿ͖ى͜͞ΕΔ໰୊ͷ͜ͱ 4

Slide 5

Slide 5 text

ྫ͑͹RemixͳΒ… 5 export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); formData.get("emal"); … } Typo͍ͯ͠Δ͕ ಈ͘ͷ͸ಈ͘

Slide 6

Slide 6 text

ྫ͑͹RemixͳΒ… 6 export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); formData.get("email"); … } मਖ਼Λͯ͠σϓϩΠ͢Δ

Slide 7

Slide 7 text

ΫϥΠΞϯτͱαʔόͷόʔδϣϯ ࣌ܥྻ ΫϥΠΞϯτ αʔό 1.࠷ॳͷόʔδϣϯΛσϓϩΠ͢Δ - v1 2.Ϣʔβ͕ΞΫηε͢Δ v1 v1 3.मਖ਼൛ΛσϓϩΠ͢Δ v1 v2 4.Ϣʔβ͕αϒϛοτ͢Δ v1 v2 7 ؆୯ʹࠩҟ͕ग़ͯɺઌఔͷྫͩͱΤϥʔʹͳΔ 7

Slide 8

Slide 8 text

Ͳ͏ରࡦ͢Δ͔ 1. ޙํޓ׵Λߟ͑ͭͭɺίʔυΛमਖ਼͢Δ େମ͸͜ͷํ๏͕औΒΕΔͱࢥ͏ 2. ΫϥΠΞϯτΛαʔόͷόʔδϣϯʹ͢Δ ϑϩϯτ͕ͦ΋ͦ΋ݹ͍͜ͱΛ໰୊ͱͯ͠ରॲ͢Δํ๏ 8 3. αʔόΛΫϥΠΞϯτͷόʔδϣϯʹ߹ΘͤΔ ݹ͍··ͳΒݹ͍αʔό؀ڥͰಈ࡞͢Δ
 (ύεʹ /api/v1 తͳߟ͑) 8

Slide 9

Slide 9 text

Ͳ͏ରࡦ͢Δ͔ 1. ޙํޓ׵Λߟ͑ͭͭɺίʔυΛमਖ਼͢Δ େମ͸͜ͷํ๏͕औΒΕΔͱࢥ͏ 2. ΫϥΠΞϯτΛαʔόͷόʔδϣϯʹ͢Δ ϑϩϯτ͕ͦ΋ͦ΋ݹ͍͜ͱΛ໰୊ͱͯ͠ରॲ͢Δํ๏ 9 3. αʔόΛΫϥΠΞϯτͷόʔδϣϯʹ߹ΘͤΔ ݹ͍··ͳΒݹ͍αʔό؀ڥͰಈ࡞͢Δ
 (ύεʹ /api/v1 తͳߟ͑) ͜ΕΛ࿩͠·͢ 9

Slide 10

Slide 10 text

ΫϥΠΞϯτͰରԠ͢Δ 2 10

Slide 11

Slide 11 text

“ ΫϥΠΞϯτ͕ݹ͍ͷͰαʔόͷ
 όʔδϣϯʢ࠷৽ʣʹߋ৽͢Δ 11

Slide 12

Slide 12 text

“ ͍ͭʁ🤔 12

Slide 13

Slide 13 text

“ Ϣʔβʹҧ࿨ײ༩͑ͳ͍λΠϛϯά͸ ϧʔςΟϯάͷมߋ࣌ 13

Slide 14

Slide 14 text

ΫϥΠΞϯτΛߋ৽͢ΔLinkίϯϙʔωϯτ 14 import { type LinkProps, Link as RemixLink, useHref } from "@remix-run/react"; import { forwardRef, useCallback } from "react"; import clientVersion from "~/version.json"; export const Link = forwardRef((props, ref) => { const { onClick, ...rest } = props; const href = useHref(props.to, { relative: props.relative }); const handleClick = useCallback( async (e: React.MouseEvent) => { onClick?.(e); if (e.defaultPrevented) { return; } const currentUrl = new URL(window.location.href); const res = await fetch(new URL("/version.json", currentUrl).href); if (res.ok) { const serverVersion = await res.json<{ version: string }>(); if (serverVersion.version !== clientVersion.version) { location.href = href; } } }, [onClick, href], ); return ; });

Slide 15

Slide 15 text

େࣄͳͱ͜Ζ͚ͩ 15 import clientVersion from "~/version.json"; export const Link = forwardRef((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent) => { … const currentUrl = new URL(window.location.href); const res = await fetch(new URL("/version.json", currentUrl).href); if (res.ok) { const serverVersion = await res.json<{ version: string }>(); if (serverVersion.version !== clientVersion.version) { location.href = href; } } }, [onClick, href], ); return ; });

Slide 16

Slide 16 text

େࣄͳͱ͜Ζ͚ͩ 16 import clientVersion from "~/version.json"; export const Link = forwardRef((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent) => { … const currentUrl = new URL(window.location.href); const res = await fetch(new URL("/version.json", currentUrl).href); if (res.ok) { const serverVersion = await res.json<{ version: string }>(); if (serverVersion.version !== clientVersion.version) { location.href = href; } } }, [onClick, href], ); return ; }); αʔόͷόʔδϣϯΛऔಘͯ͘͠Δ

Slide 17

Slide 17 text

େࣄͳͱ͜Ζ͚ͩ 17 import clientVersion from "~/version.json"; export const Link = forwardRef((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent) => { … const currentUrl = new URL(window.location.href); const res = await fetch(new URL("/version.json", currentUrl).href); if (res.ok) { const serverVersion = await res.json<{ version: string }>(); if (serverVersion.version !== clientVersion.version) { location.href = href; } } }, [onClick, href], ); return ; }); όʔδϣϯࠩҟ͕͋Ε͹ϖʔδ͝ͱ࠶औಘ͢Δ

Slide 18

Slide 18 text

“ ҰԠ͜ΕͰ΋ʢͦΕͬΆ͘͸ʣಈ͘ɻ ͨͩɺ໰୊͕͋Δɻ 18

Slide 19

Slide 19 text

େࣄͳͱ͜Ζ͚ͩ 19 import clientVersion from "~/version.json"; export const Link = forwardRef((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent) => { … const currentUrl = new URL(window.location.href); const res = await fetch(new URL("/version.json", currentUrl).href); if (res.ok) { const serverVersion = await res.json<{ version: string }>(); if (serverVersion.version !== clientVersion.version) { location.href = href; } } }, [onClick, href], ); return ; }); ඇಉظॲཧͳͷͰϖʔδ࠶औಘΛ͢Δલʹ
 ϧʔςΟϯά͸طʹ࣮ߦ͞Ε͍ͯΔʂʂ

Slide 20

Slide 20 text

ରࡦͱͯ͠͸ඍົ 1. ϧʔςΟϯά͕ߦΘΕ͍ͯΔͷͰloader͸࣮ߦ͞ΕΔ loader͔ΒͷJSONσʔλͷऔಘ͸૸͍ͬͯΔ 2. ͞ΒʹϖʔδΛߋ৽͢ΔͷͰ࠶౓loader͕࣮ߦ͞ΕΔ ϖʔδߋ৽Λ͢Δʹloader͕ࣄ্࣮2ճ࣮ߦ͞ΕΔ 20 3. LinkίϯϙʔωϯτҎ֎ʹFromίϯϙʔωϯτ΍ useFetcherͳͲ΋ରԠ͕ඞཁʹͳΔ ରԠͤ͞Δʹ͸৭ʑͳॲཧΛϥοϓ͢Δඞཁ͕͋Δɻ
 ಛʹuseSubmit͸໽հͰ͋Δɻ 20

Slide 21

Slide 21 text

αʔόͰରԠ͢Δ 3 21

Slide 22

Slide 22 text

“ αʔόଆͰΫϥΠΞϯτͱͷ όʔδϣϯൺֱΛߦ͏ 22

Slide 23

Slide 23 text

“ 23 ΫϥΠΞϯτͷόʔδϣϯΛ Ͳ͏΍ͬͯαʔόʹૹΔʁ🤔

Slide 24

Slide 24 text

“ 🍪 24

Slide 25

Slide 25 text

“ 25 ॳճʹϖʔδΛऔಘͨ͠ࡍʹ ͦͷ࣌ͷόʔδϣϯΛCookieΛ ৯΂͓͚ͤͯ͞͹ϦΫΤετ࣌ʹ औಘ࣌ͷόʔδϣϯΛૹͬͯ͘Δ

Slide 26

Slide 26 text

entry.server.tsxͰόʔδϣϯΛૹ͓ͬͯ͘ 26 import { renderToReadableStream } from "react-dom/server"; import versionJson from "./version.json"; export default async function handleRequest( responseStatusCode: number, responseHeaders: Headers, ) { let isError = false; const body = await renderToReadableStream(...); … responseHeaders.append( "Set-Cookie", `version=${versionJson.version}; Max-Age=${60 * 60 * 24 * 365}; Path=/; SameSite=Lax; Secure; HttpOnly`, ); return new Response(body, { headers: responseHeaders, status: isError ? 500 : responseStatusCode, }); } CookieʹόʔδϣϯΛ ઃఆ͓ͯ͘͠

Slide 27

Slide 27 text

load-context.tsʹόʔδϣϯൺֱΛߦ͏ॲཧΛ ఆٛ͢Δ 27 import versionJson from "./app/version.json"; const getClientVersion = (request: Request) => { const cookieHeader = request.headers.get("Cookie"); const clientVersion = cookieHeader ?.split(";") ?.find((c) => c.trim().startsWith("version=")) ?.split("=")[1]; return clientVersion; }; const isSameVersion = (request: Request) => { const clientVersion = getClientVersion(request); return clientVersion === versionJson.version; }; export const validSameVersion = async (request: Request) => { const url = new URL(request.url); if (!url.searchParams.has(" data")) { return; } const isSame = isSameVersion(request); if (!isSame) { url.searchParams.delete(" data"); throw redirectDocument(url.toString()); } }; export const getLoadContext: GetLoadContext = ({ context, request }) => { return { ...context, checkSameVersion() { validSameVersion(request); }, }; };

Slide 28

Slide 28 text

load-context.tsʹόʔδϣϯൺֱΛߦ͏ॲཧΛ ఆٛ͢Δ 28 const isSameVersion = async (request: Request) => { const clientVersion = getClientVersion(request); return clientVersion === versionJson.version; }; export const validSameVersion = (request: Request) => { const url = new URL(request.url); if (!url.searchParams.has(" data")) { return; } const isSame = isSameVersion(request); if (!isSame) { url.searchParams.delete("_data"); throw redirectDocument(url.toString()); } }; ϖʔδऔಘͷϦΫΤετ
 Ͱ͸ൺֱ͠ͳ͍
 ʢ࠷৽͕औಘ͞ΕΔͨΊʣ

Slide 29

Slide 29 text

load-context.tsʹόʔδϣϯൺֱΛߦ͏ॲཧΛ ఆٛ͢Δ 29 const isSameVersion = async (request: Request) => { const clientVersion = getClientVersion(request); return clientVersion === versionJson.version; }; export const validSameVersion = (request: Request) => { const url = new URL(request.url); if (!url.searchParams.has("_data")) { return; } const isSame = isSameVersion(request); if (!isSame) { url.searchParams.delete("_data"); throw redirectDocument(url.toString()); } }; JSONऔಘϦΫΤετ࣌ʹૹ͖ͬͯͨ࣌ʹൺֱ͢Δɻ
 Cookieʹ͋ΔόʔδϣϯͱαʔόͷόʔδϣϯΛ
 ൺֱͯ͠ҟͳΕ͹ ʮredirectDocumentʯ Ͱ
 ࢦఆURLΛϦϩʔυͤ͞Δ

Slide 30

Slide 30 text

࡞੒ͨ͠contextͷؔ਺Λloaderʹ࢓ࠐΉ 30 export const loader = async ({ context }: LoaderFunctionArgs) => { await context.checkSameVersion(); … return { }; }; όʔδϣϯ͕ҟͳΕ͹ ʮredirectDocumentʯ Λthrowͯ͠ΔͷͰ loaderͷޙଓॲཧ͸ߦΘΕͳ͍

Slide 31

Slide 31 text

“ 31 ΊͰͨ͠ΊͰͨ͠👏

Slide 32

Slide 32 text

“ 32 🙅

Slide 33

Slide 33 text

“ loader͸redirectͰ
 ରॲ͚ͨ͠Ͳaction͸ʁ🤔 33

Slide 34

Slide 34 text

“ 🚨 Caution 🚨 ͔͜͜Β͸͔ͳΓྗٕͰ͢ 34

Slide 35

Slide 35 text

“ શόʔδϣϯ͝ͱͷ؀ڥΛ ͢΂ͯ४උ͓͍ͯͯ͠ɺ ΫϥΠΞϯτͷόʔδϣϯʹ ରԠͨ͠αʔόͰॲཧ͢Δ💪 35

Slide 36

Slide 36 text

ߏஙΠϝʔδ 36 v1 དྷͨϦΫΤετͷΫϥΠΞϯτͷόʔδϣϯʹΑͬͯ
 ϦΫΤετઌͷRemixΛৼΓ෼͚Δϩʔυόϥϯαʔͷ
 Α͏ͳ΋ͷΛΠϝʔδ v2 v3 v4 v5 v2 v3 v4 v5 v1

Slide 37

Slide 37 text

“ 37

Slide 38

Slide 38 text

Cloudflare PagesͷPreview deploymentsΛ࢖͏ 38 ● PagesͷϓϩδΣΫτʹຊ൪ͱಉ͡ઃఆʢҧ͏ઃఆ΋ ग़དྷΔʣͰผURLͷ؀ڥΛ࡞ͬͯ͘ΕΔ ● ແ੍ݶʹ؀ڥΛ࡞ΕΔ ● ࢲ͕࢖͏جຊతͳ༻్ͱͯ͠ຊ൪ʹग़͢લͷ࠷ऴ֬ೝ Ͱ࢖͏͜ͱ͕ଟ͍ υΩϡϝϯτ͸ͪ͜ΒʢˠQRͰಡΈࠐΉͷָ͕ʣ
 https://developers.cloudflare.com/pages/platform/limits/#preview-deployments 38

Slide 39

Slide 39 text

σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: GitHub Actionsʣ 39 - name: Get short SHA id: get sha run: echo "short sha=$(git rev-parse --short HEAD)" >> $GITHUB OUTPUT - name: Deploy to Cloudflare Pages run: | echo '{"version":"${{ github.sha }}"}' > public/version.json echo '{"version":"${{ github.sha }}"}' > app/version.json npm run build wrangler pages deploy --branch ${{ steps.get sha.outputs.short sha }} wrangler pages deploy --branch main shell: bash ※Θ͔Γ΍͍͢Α͏ʹΘ͟ͱwrangler cliͰॻ͍͍ͯ·͢

Slide 40

Slide 40 text

σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: GitHub Actionsʣ 40 - name: Get short SHA id: get_sha run: echo "short sha=$(git rev-parse --short HEAD)" >> $GITHUB OUTPUT - name: Deploy to Cloudflare Pages run: | echo '{"version":"${{ github.sha }}"}' > public/version.json echo '{"version":"${{ github.sha }}"}' > app/version.json npm run build wrangler pages deploy --branch ${{ steps.get sha.outputs.short sha }} wrangler pages deploy --branch main shell: bash σϓϩΠ࣌ͷόʔδϣϯΛܾΊΔ

Slide 41

Slide 41 text

σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: GitHub Actionsʣ 41 - name: Get short SHA id: get_sha run: echo "short sha=$(git rev-parse --short HEAD)" >> $GITHUB OUTPUT - name: Deploy to Cloudflare Pages run: | echo '{"version":"${{ github.sha }}"}' > public/version.json echo '{"version":"${{ github.sha }}"}' > app/version.json npm run build wrangler pages deploy --branch ${{ steps.get sha.outputs.short sha }} wrangler pages deploy --branch main shell: bash ಛఆόʔδϣϯͷ؀ڥʹσϓϩΠ͢Δ branchΦϓγϣϯͰΛ࢖༻͢Δͱ
 https://..pages.dev ͱ͍͏ಠཱͨ͠؀ڥ͕࡞੒͞ΕΔ

Slide 42

Slide 42 text

σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: GitHub Actionsʣ 42 - name: Get short SHA id: get sha run: echo "short sha=$(git rev-parse --short HEAD)" >> $GITHUB OUTPUT - name: Deploy to Cloudflare Pages run: | echo '{"version":"${{ github.sha }}"}' > public/version.json echo '{"version":"${{ github.sha }}"}' > app/version.json npm run build wrangler pages deploy --branch ${{ steps.get sha.outputs.short sha }} wrangler pages deploy --branch main shell: bash ຊ൪ͷ؀ڥʹσϓϩΠ͢Δ

Slide 43

Slide 43 text

“ MiddlewareͰΫϥΠΞϯτͷ
 όʔδϣϯʹରԠͨ͠αʔόʹ
 ׂΓৼͬͯResponseΛฦ͢ 43

Slide 44

Slide 44 text

const isOldVersionRequest = (request: Request) => { const method = request.method.toUpperCase(); const clientVersion = getClientVersion(request); return ( clientVersion && clientVersion !== serverVersion.version && method !== "GET" ); }; functions/_middleware.tsʹͯϦΫΤετΛׂΓৼΔ 44 const originRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); const shortVersion = getClientVersion(context.request)?.slice(0, 8); if (shortVersion && isOldVersionRequest(context.request)) { url.hostname = `${shortVersion}.${context.env.PROJECT_NAME}.pages.dev`; return await fetch(url.toString(), context.request); } return await context.next(); }; export const onRequest = [originRequest];

Slide 45

Slide 45 text

const isOldVersionRequest = (request: Request) => { const method = request.method.toUpperCase(); const clientVersion = getClientVersion(request); return ( clientVersion && clientVersion !== serverVersion.version && method !== "GET" ); }; functions/_middleware.tsʹͯϦΫΤετΛׂΓৼΔ 45 const originRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); const shortVersion = getClientVersion(context.request)?.slice(0, 8); if (shortVersion && isOldVersionRequest(context.request)) { url.hostname = `${shortVersion}.${context.env.PROJECT_NAME}.pages.dev`; return await fetch(url.toString(), context.request); } return await context.next(); }; export const onRequest = [originRequest]; چόʔδϣϯ͔νΣοΫ

Slide 46

Slide 46 text

const isOldVersionRequest = (request: Request) => { const method = request.method.toUpperCase(); const clientVersion = getClientVersion(request); return ( clientVersion && clientVersion !== serverVersion.version && method !== "GET" ); }; functions/_middleware.tsʹͯϦΫΤετΛׂΓৼΔ 46 const originRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); const shortVersion = getClientVersion(context.request)?.slice(0, 8); if (shortVersion && isOldVersionRequest(context.request)) { url.hostname = `${shortVersion}.${context.env.PROJECT_NAME}.pages.dev`; return await fetch(url.toString(), context.request); } return await context.next(); }; export const onRequest = [originRequest]; چόʔδϣϯͷ৔߹͸ରԠͨ͠αʔό͔ΒResponseΛ࡞੒͢Δ

Slide 47

Slide 47 text

const isOldVersionRequest = (request: Request) => { const method = request.method.toUpperCase(); const clientVersion = getClientVersion(request); return ( clientVersion && clientVersion !== serverVersion.version && method !== "GET" ); }; functions/_middleware.tsʹͯϦΫΤετΛׂΓৼΔ 47 const originRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); const shortVersion = getClientVersion(context.request)?.slice(0, 8); if (shortVersion && isOldVersionRequest(context.request)) { url.hostname = `${shortVersion}.${context.env.PROJECT_NAME}.pages.dev`; return await fetch(url.toString(), context.request); } return await context.next(); }; export const onRequest = [originRequest]; ࠷৽ͷ৔߹͸ࣗ෼Ͱ
 ResponseΛ࡞੒͢Δ

Slide 48

Slide 48 text

·ͱΊ 4 48

Slide 49

Slide 49 text

·ͱΊ ● Version skewͷ׬શͳରԠΛߦ͏ʹ͸ͦΕͳΓͷ؀ ڥߏங͕ඞཁͰ͢ɻ ● loaderͷॲཧ͚ͩͰ΋ೖΕ͓ͯ͘ͷ͕Φεεϝ͠ Ͱ͢ɻ໰୊͕ൃੜ͠ʹ͘͘ͳΔͱಉ࣌ʹDOMߋ৽ ʹ΋ڧ͘ͳΔͷͰΦεεϝͰ͢ɻʢͨͩ͠loader͕ ແ͍ϖʔδ͸ಈ࡞͠ͳ͍ʣ ● Cloudflare Pages࢖͍ͬͯΔͳΒPreview deployments͸ΞϓϦέʔγϣϯӡ༻ʹ৺ڧ͍ຯ ํͳͷͰ࢖༻ਪ঑Ͱ͢ɻ 49 49

Slide 50

Slide 50 text

Thank you! name: chimame / rito job: WebΤϯδχΞ field: Cloudflare, GCP, AWS, Ruby, Node.js, TypeScript, React, Next.js, Remix, Docker etc company: Goensגࣜձࣾ( https://about.goen-s.com ) twitter: @chimame_rt GitHub: chimame 50 Any questions?