Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RemixでVersion skewに立ち向かう

chimame
September 25, 2024

RemixでVersion skewに立ち向かう

Remix Tokyo Meetup #2

chimame

September 25, 2024
Tweet

More Decks by chimame

Other Decks in Technology

Transcript

  1. ྫ͑͹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͍ͯ͠Δ͕ ಈ͘ͷ͸ಈ͘
  2. ྫ͑͹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"); … } मਖ਼Λͯ͠σϓϩΠ͢Δ
  3. ΫϥΠΞϯτͱαʔόͷόʔδϣϯ ࣌ܥྻ ΫϥΠΞϯτ αʔό 1.࠷ॳͷόʔδϣϯΛσϓϩΠ͢Δ - v1 2.Ϣʔβ͕ΞΫηε͢Δ v1 v1

    3.मਖ਼൛ΛσϓϩΠ͢Δ v1 v2 4.Ϣʔβ͕αϒϛοτ͢Δ v1 v2 7 ؆୯ʹࠩҟ͕ग़ͯɺઌఔͷྫͩͱΤϥʔʹͳΔ 7
  4. ΫϥΠΞϯτΛߋ৽͢Δ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} />; });
  5. େࣄͳͱ͜Ζ͚ͩ 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} />; });
  6. େࣄͳͱ͜Ζ͚ͩ 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} />; }); αʔόͷόʔδϣϯΛऔಘͯ͘͠Δ
  7. େࣄͳͱ͜Ζ͚ͩ 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} />; }); όʔδϣϯࠩҟ͕͋Ε͹ϖʔδ͝ͱ࠶औಘ͢Δ
  8. େࣄͳͱ͜Ζ͚ͩ 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} />; }); ඇಉظॲཧͳͷͰϖʔδ࠶औಘΛ͢Δલʹ
 ϧʔςΟϯά͸طʹ࣮ߦ͞Ε͍ͯΔʂʂ
  9. 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ʹόʔδϣϯΛ ઃఆ͓ͯ͘͠
  10. 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); }, }; };
  11. 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()); } }; ϖʔδऔಘͷϦΫΤετ
 Ͱ͸ൺֱ͠ͳ͍
 ʢ࠷৽͕औಘ͞ΕΔͨΊʣ
  12. 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ΛϦϩʔυͤ͞Δ
  13. ࡞੒ͨ͠contextͷؔ਺Λloaderʹ࢓ࠐΉ 30 export const loader = async ({ context }:

    LoaderFunctionArgs) => { await context.checkSameVersion(); … return { }; }; όʔδϣϯ͕ҟͳΕ͹ ʮredirectDocumentʯ Λthrowͯ͠ΔͷͰ loaderͷޙଓॲཧ͸ߦΘΕͳ͍
  14. Cloudflare PagesͷPreview deploymentsΛ࢖͏ 38 • PagesͷϓϩδΣΫτʹຊ൪ͱಉ͡ઃఆʢҧ͏ઃఆ΋ ग़དྷΔʣͰผURLͷ؀ڥΛ࡞ͬͯ͘ΕΔ • ແ੍ݶʹ؀ڥΛ࡞ΕΔ •

    ࢲ͕࢖͏جຊతͳ༻్ͱͯ͠ຊ൪ʹग़͢લͷ࠷ऴ֬ೝ Ͱ࢖͏͜ͱ͕ଟ͍ υΩϡϝϯτ͸ͪ͜ΒʢˠQRͰಡΈࠐΉͷָ͕ʣ
 https://developers.cloudflare.com/pages/platform/limits/#preview-deployments 38
  15. σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: 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Ͱॻ͍͍ͯ·͢
  16. σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: 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 σϓϩΠ࣌ͷόʔδϣϯΛܾΊΔ
  17. σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: 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 ͱ͍͏ಠཱͨ͠؀ڥ͕࡞੒͞ΕΔ
  18. σϓϩΠ࣌ʹόʔδϣϯຖͷ؀ڥ΋༻ҙ͓ͯ͘͠
 ʢྫ: 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 ຊ൪ͷ؀ڥʹσϓϩΠ͢Δ
  19. 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];
  20. 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]; چόʔδϣϯ͔νΣοΫ
  21. 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Λ࡞੒͢Δ
  22. 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Λ࡞੒͢Δ
  23. 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?