Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
RemixでVersion skewに立ち向かう
Search
Sponsored
·
SiteGround - Reliable hosting with speed, security, and support you can count on.
→
chimame
September 25, 2024
Technology
1.3k
2
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
RemixでVersion skewに立ち向かう
Remix Tokyo Meetup #2
chimame
September 25, 2024
More Decks by chimame
See All by chimame
知って得する@cloudflare_vite-pluginのあれこれ
chimame
2
560
Boost Your Web Performance with Hyperdrive
chimame
1
510
私がエッジを使う理由
chimame
10
4.1k
GraphQL Server on Edge after that
chimame
1
1.7k
Accelerating App Dev with Cloudflare Workers
chimame
1
490
GraphQL Server on Edge
chimame
12
6.4k
エッジで輝くフロントエンド
chimame
11
6.9k
Cloudflare Workersと状態管理
chimame
4
2.1k
CSRなサイトを (疑似的な)ISRに変更した話
chimame
0
710
Other Decks in Technology
See All in Technology
フロンティアAIのゲート化と地政学リスク
nagatsu
0
130
攻撃者視点で考えるDetection Engineering
cryptopeg
2
1.5k
社内 AI エージェント Synapse と セマンティックレイヤーの育て方
hiroakis
2
1.8k
Oracle AI Database@Google Cloud:サービス概要のご紹介
oracle4engineer
PRO
6
1.5k
人材育成分科会.pdf
_awache
0
120
小さく始める AI 活用推進 ― 日経電子版 Web チームの事例/nikkei-tech-talk47
nikkei_engineer_recruiting
0
250
Snowflakeと仲良くなる第一歩
coco_se
4
440
自律型AIエージェントは何を破壊するのか
kojira
0
160
Android の公式 Skill / Android skills
yanzm
0
140
On-behalf-of Token exchange with AgentCore Identity
hironobuiga
2
160
チームで進めるAI駆動アジャイル×ウォーターフォール
kumaiu
0
160
【Cyber-sec+】経営層を"動かす"ための考え方
hssh2_bin
0
150
Featured
See All Featured
Are puppies a ranking factor?
jonoalderson
1
3.5k
Agile that works and the tools we love
rasmusluckow
331
21k
<Decoding/> the Language of Devs - We Love SEO 2024
nikkihalliwell
1
240
Visualizing Your Data: Incorporating Mongo into Loggly Infrastructure
mongodb
49
10k
Paper Plane
katiecoart
PRO
1
51k
For a Future-Friendly Web
brad_frost
183
10k
Ecommerce SEO: The Keys for Success Now & Beyond - #SERPConf2024
aleyda
1
2k
Why Mistakes Are the Best Teachers: Turning Failure into a Pathway for Growth
auna
0
160
How to optimise 3,500 product descriptions for ecommerce in one day using ChatGPT
katarinadahlin
PRO
1
3.6k
Build your cross-platform service in a week with App Engine
jlugia
234
18k
Crafting Experiences
bethany
1
180
What’s in a name? Adding method to the madness
productmarketing
PRO
24
4.1k
Transcript
RemixͰVersion skewʹཱ͔ͪ͏ Remix Tokyo Meetup #2 2024.09.25
contents - Version skewͱ - ΫϥΠΞϯτͰରԠ͢Δ - αʔόͰରԠ͢Δ - ·ͱΊ
2
Version skewͱ 1 3
“ Version skewͱͦͷ··ͷ௨Γ όʔδϣϯͷࠩҟʹΑͬͯ Ҿ͖ى͜͞ΕΔͷ͜ͱ 4
ྫ͑RemixͳΒ… 5 <Form method="post"> <input type="email" name="emal" required /> <textarea
name="message" required /> </Form> export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); formData.get("emal"); … } Typo͍ͯ͠Δ͕ ಈ͘ͷಈ͘
ྫ͑RemixͳΒ… 6 <Form method="post"> <input type="email" name="email" required /> <textarea
name="message" required /> </Form> export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); formData.get("email"); … } मਖ਼Λͯ͠σϓϩΠ͢Δ
ΫϥΠΞϯτͱαʔόͷόʔδϣϯ ࣌ܥྻ ΫϥΠΞϯτ αʔό 1.࠷ॳͷόʔδϣϯΛσϓϩΠ͢Δ - v1 2.Ϣʔβ͕ΞΫηε͢Δ v1 v1
3.मਖ਼൛ΛσϓϩΠ͢Δ v1 v2 4.Ϣʔβ͕αϒϛοτ͢Δ v1 v2 7 ؆୯ʹࠩҟ͕ग़ͯɺઌఔͷྫͩͱΤϥʔʹͳΔ 7
Ͳ͏ରࡦ͢Δ͔ 1. ޙํޓΛߟ͑ͭͭɺίʔυΛमਖ਼͢Δ େମ͜ͷํ๏͕औΒΕΔͱࢥ͏ 2. ΫϥΠΞϯτΛαʔόͷόʔδϣϯʹ͢Δ ϑϩϯτ͕ͦͦݹ͍͜ͱΛͱͯ͠ରॲ͢Δํ๏ 8 3. αʔόΛΫϥΠΞϯτͷόʔδϣϯʹ߹ΘͤΔ
ݹ͍··ͳΒݹ͍αʔόڥͰಈ࡞͢Δ (ύεʹ /api/v1 తͳߟ͑) 8
Ͳ͏ରࡦ͢Δ͔ 1. ޙํޓΛߟ͑ͭͭɺίʔυΛमਖ਼͢Δ େମ͜ͷํ๏͕औΒΕΔͱࢥ͏ 2. ΫϥΠΞϯτΛαʔόͷόʔδϣϯʹ͢Δ ϑϩϯτ͕ͦͦݹ͍͜ͱΛͱͯ͠ରॲ͢Δํ๏ 9 3. αʔόΛΫϥΠΞϯτͷόʔδϣϯʹ߹ΘͤΔ
ݹ͍··ͳΒݹ͍αʔόڥͰಈ࡞͢Δ (ύεʹ /api/v1 తͳߟ͑) ͜ΕΛ͠·͢ 9
ΫϥΠΞϯτͰରԠ͢Δ 2 10
“ ΫϥΠΞϯτ͕ݹ͍ͷͰαʔόͷ όʔδϣϯʢ࠷৽ʣʹߋ৽͢Δ 11
“ ͍ͭʁ🤔 12
“ Ϣʔβʹҧײ༩͑ͳ͍λΠϛϯά ϧʔςΟϯάͷมߋ࣌ 13
ΫϥΠΞϯτΛߋ৽͢Δ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<HTMLAnchorElement, LinkProps>((props, ref) => { const { onClick, ...rest } = props; const href = useHref(props.to, { relative: props.relative }); const handleClick = useCallback( async (e: React.MouseEvent<HTMLAnchorElement, 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 <RemixLink ref={ref} {...rest} onClick={handleClick} />; });
େࣄͳͱ͜Ζ͚ͩ 15 import clientVersion from "~/version.json"; export const Link =
forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent<HTMLAnchorElement, 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 <RemixLink ref={ref} {...rest} onClick={handleClick} />; });
େࣄͳͱ͜Ζ͚ͩ 16 import clientVersion from "~/version.json"; export const Link =
forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent<HTMLAnchorElement, 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 <RemixLink ref={ref} {...rest} onClick={handleClick} />; }); αʔόͷόʔδϣϯΛऔಘͯ͘͠Δ
େࣄͳͱ͜Ζ͚ͩ 17 import clientVersion from "~/version.json"; export const Link =
forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent<HTMLAnchorElement, 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 <RemixLink ref={ref} {...rest} onClick={handleClick} />; }); όʔδϣϯࠩҟ͕͋Εϖʔδ͝ͱ࠶औಘ͢Δ
“ ҰԠ͜ΕͰʢͦΕͬΆ͘ʣಈ͘ɻ ͨͩɺ͕͋Δɻ 18
େࣄͳͱ͜Ζ͚ͩ 19 import clientVersion from "~/version.json"; export const Link =
forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => { … const handleClick = useCallback( async (e: React.MouseEvent<HTMLAnchorElement, 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 <RemixLink ref={ref} {...rest} onClick={handleClick} />; }); ඇಉظॲཧͳͷͰϖʔδ࠶औಘΛ͢Δલʹ ϧʔςΟϯάطʹ࣮ߦ͞Ε͍ͯΔʂʂ
ରࡦͱͯ͠ඍົ 1. ϧʔςΟϯά͕ߦΘΕ͍ͯΔͷͰloader࣮ߦ͞ΕΔ loader͔ΒͷJSONσʔλͷऔಘ͍ͬͯΔ 2. ͞ΒʹϖʔδΛߋ৽͢ΔͷͰ࠶loader͕࣮ߦ͞ΕΔ ϖʔδߋ৽Λ͢Δʹloader͕ࣄ্࣮2ճ࣮ߦ͞ΕΔ 20 3. LinkίϯϙʔωϯτҎ֎ʹFromίϯϙʔωϯτ
useFetcherͳͲରԠ͕ඞཁʹͳΔ ରԠͤ͞Δʹ৭ʑͳॲཧΛϥοϓ͢Δඞཁ͕͋Δɻ ಛʹuseSubmitհͰ͋Δɻ 20
αʔόͰରԠ͢Δ 3 21
“ αʔόଆͰΫϥΠΞϯτͱͷ όʔδϣϯൺֱΛߦ͏ 22
“ 23 ΫϥΠΞϯτͷόʔδϣϯΛ Ͳ͏ͬͯαʔόʹૹΔʁ🤔
“ 🍪 24
“ 25 ॳճʹϖʔδΛऔಘͨ͠ࡍʹ ͦͷ࣌ͷόʔδϣϯΛCookieΛ ৯͓͚ͤͯ͞ϦΫΤετ࣌ʹ औಘ࣌ͷόʔδϣϯΛૹͬͯ͘Δ
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ʹόʔδϣϯΛ ઃఆ͓ͯ͘͠
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); }, }; };
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()); } }; ϖʔδऔಘͷϦΫΤετ Ͱൺֱ͠ͳ͍ ʢ࠷৽͕औಘ͞ΕΔͨΊʣ
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ΛϦϩʔυͤ͞Δ
࡞ͨ͠contextͷؔΛloaderʹࠐΉ 30 export const loader = async ({ context }:
LoaderFunctionArgs) => { await context.checkSameVersion(); … return { }; }; όʔδϣϯ͕ҟͳΕ ʮredirectDocumentʯ Λthrowͯ͠ΔͷͰ loaderͷޙଓॲཧߦΘΕͳ͍
“ 31 ΊͰͨ͠ΊͰͨ͠👏
“ 32 🙅
“ loaderredirectͰ ରॲ͚ͨ͠Ͳactionʁ🤔 33
“ 🚨 Caution 🚨 ͔͜͜Β͔ͳΓྗٕͰ͢ 34
“ શόʔδϣϯ͝ͱͷڥΛ ͯ͢४උ͓͍ͯͯ͠ɺ ΫϥΠΞϯτͷόʔδϣϯʹ ରԠͨ͠αʔόͰॲཧ͢Δ💪 35
ߏஙΠϝʔδ 36 v1 དྷͨϦΫΤετͷΫϥΠΞϯτͷόʔδϣϯʹΑͬͯ ϦΫΤετઌͷRemixΛৼΓ͚Δϩʔυόϥϯαʔͷ Α͏ͳͷΛΠϝʔδ v2 v3 v4 v5
v2 v3 v4 v5 v1
“ 37
Cloudflare PagesͷPreview deploymentsΛ͏ 38 • PagesͷϓϩδΣΫτʹຊ൪ͱಉ͡ઃఆʢҧ͏ઃఆ ग़དྷΔʣͰผURLͷڥΛ࡞ͬͯ͘ΕΔ • ແ੍ݶʹڥΛ࡞ΕΔ •
ࢲ͕͏جຊతͳ༻్ͱͯ͠ຊ൪ʹग़͢લͷ࠷ऴ֬ೝ Ͱ͏͜ͱ͕ଟ͍ υΩϡϝϯτͪ͜ΒʢˠQRͰಡΈࠐΉͷָ͕ʣ https://developers.cloudflare.com/pages/platform/limits/#preview-deployments 38
σϓϩΠ࣌ʹόʔδϣϯຖͷڥ༻ҙ͓ͯ͘͠ ʢྫ: 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Ͱॻ͍͍ͯ·͢
σϓϩΠ࣌ʹόʔδϣϯຖͷڥ༻ҙ͓ͯ͘͠ ʢྫ: 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 σϓϩΠ࣌ͷόʔδϣϯΛܾΊΔ
σϓϩΠ࣌ʹόʔδϣϯຖͷڥ༻ҙ͓ͯ͘͠ ʢྫ: 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://<branch>.<project name>.pages.dev ͱ͍͏ಠཱͨ͠ڥ͕࡞͞ΕΔ
σϓϩΠ࣌ʹόʔδϣϯຖͷڥ༻ҙ͓ͯ͘͠ ʢྫ: 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 ຊ൪ͷڥʹσϓϩΠ͢Δ
“ MiddlewareͰΫϥΠΞϯτͷ όʔδϣϯʹରԠͨ͠αʔόʹ ׂΓৼͬͯResponseΛฦ͢ 43
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<Env> = 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];
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<Env> = 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]; چόʔδϣϯ͔νΣοΫ
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<Env> = 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Λ࡞͢Δ
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<Env> = 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Λ࡞͢Δ
·ͱΊ 4 48
·ͱΊ • Version skewͷશͳରԠΛߦ͏ʹͦΕͳΓͷ ڥߏங͕ඞཁͰ͢ɻ • loaderͷॲཧ͚ͩͰೖΕ͓ͯ͘ͷ͕Φεεϝ͠ Ͱ͢ɻ͕ൃੜ͠ʹ͘͘ͳΔͱಉ࣌ʹDOMߋ৽ ʹڧ͘ͳΔͷͰΦεεϝͰ͢ɻʢͨͩ͠loader͕ ແ͍ϖʔδಈ࡞͠ͳ͍ʣ
• Cloudflare Pages͍ͬͯΔͳΒPreview deploymentsΞϓϦέʔγϣϯӡ༻ʹ৺ڧ͍ຯ ํͳͷͰ༻ਪͰ͢ɻ 49 49
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?