Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Sign up for free
Menu
Search
Features
All features
Private URLs
Password Protection
Custom URLS
Scheduled publishing
Remove Branding
Restrict embedding
Deck Collections
Notes
Features
All features
Private URLs
Password Protection
Custom URLS
Scheduled publishing
Remove Branding
Restrict embedding
Deck Collections
Notes
Explore
Featured decks
Featured speakers
Programming
Technology
Storyboards
Explore
Featured decks
Featured speakers
Programming
Technology
Storyboards
Pricing
Search
Sign in
Sign up for free
RemixでVersion skewに立ち向かう
Search
Sponsored
·
Ship Features Fearlessly
Turn features on and off without deploys. Used by thousands of Ruby developers.
→
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の向き先
chimame
0
34
Cloudflare is Agents
chimame
0
230
知って得する@cloudflare_vite-pluginのあれこれ
chimame
2
720
Boost Your Web Performance with Hyperdrive
chimame
1
610
私がエッジを使う理由
chimame
10
4.2k
GraphQL Server on Edge after that
chimame
1
1.9k
Accelerating App Dev with Cloudflare Workers
chimame
1
530
GraphQL Server on Edge
chimame
12
6.8k
エッジで輝くフロントエンド
chimame
11
7k
Other Decks in Technology
See All in Technology
攻撃 3 時間 vs 復旧 10 時間。アクセスキー流出の非対称性を体感するために CTF を作った話
kazzpapa3
1
140
Sizing, Benchmarking and Performance Tuning Apache Flink Clusters
rmetzger
0
120
AIエージェント統制はAPIから——押さえるべき5つの要素
nagix
0
120
AX時代の自治体支援リソース再配分と役割分担の再構成:戦略とロードマップ(概要)
sishi2026
0
200
AI AgentによるEmbedded SREの取り組み
tgidgd
1
340
新米を「売り越さない」、Aurora DSQLで作る在庫ゼロ死守の限定ドロップEC
yama3133
0
180
Active Directory と Entra ID のむむむな関係
murachiakira
PRO
3
1.5k
複雑な業務システムを高速でつくるため、チーム全員をつくれる人へ
ayakurabuchi
0
130
AI時代に求められる優秀なTechLeadの振る舞い
pospome
10
13k
Building Audio Plugin Ecosystem for Android in 2026
atsushieno
2
150
セキュリティ専任チームなしで攻撃を先回りするSREが実践したSIEM on Amazon OpenSearch Service
cscengineer
PRO
0
250
AIコーディングはじめました
miso
0
150
Featured
See All Featured
The Spectacular Lies of Maps
axbom
PRO
1
1.1k
技術選定の審美眼(2025年版) / Understanding the Spiral of Technologies 2025 edition
twada
PRO
121
120k
Automating Front-end Workflow
addyosmani
1370
210k
Dealing with People You Can't Stand - Big Design 2015
cassininazir
367
27k
Mobile First: as difficult as doing things right
swwweet
225
10k
First, design no harm
axbom
PRO
2
1.4k
How Software Deployment tools have changed in the past 20 years
geshan
2
35k
[Rails World 2026] Durable orchestration on Rails: from continuation to workflow
palkan
2
1.2k
GitHub's CSS Performance
jonrohan
1033
470k
HDC tutorial
michielstock
2
1k
Designing for humans not robots
tammielis
254
26k
The Straight Up "How To Draw Better" Workshop
denniskardys
239
140k
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?