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
·
Your Podcast. Everywhere. Effortlessly.
Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
→
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 is Agents
chimame
0
220
知って得する@cloudflare_vite-pluginのあれこれ
chimame
2
700
Boost Your Web Performance with Hyperdrive
chimame
1
590
私がエッジを使う理由
chimame
10
4.2k
GraphQL Server on Edge after that
chimame
1
1.9k
Accelerating App Dev with Cloudflare Workers
chimame
1
520
GraphQL Server on Edge
chimame
12
6.8k
エッジで輝くフロントエンド
chimame
11
7k
Cloudflare Workersと状態管理
chimame
4
2.1k
Other Decks in Technology
See All in Technology
分析の民主化を支えるメタデータ整備の実践
kakehashi
PRO
4
1.4k
宣言型部分更新は何を解決しどこへ向かうのか
shuta13
1
430
"優秀な誰か"の下位互換にならないために
yut0naga1_fa
1
1.8k
事業活動を AI Ready にする攻めと守りのデータエンジニアリング / data-engineering-for-ai-ready-business
pei0804
7
2.8k
Conditional PED-ANOVA: Hyperparameter Importance in Hierarchical & Dynamic Search Spaces
pfn
PRO
0
130
味覇で作るパスタが美味い - 20261001_yabaubuki.dev
livesense
PRO
0
130
JAWSUG TOHOKU 青森支部 「LoGoチャット」のAWS移行で 乗り越えたこと
taketakekaho
0
310
QA4AI 2026-Oct 生成AI時代の品質保証
tokuty
1
840
実践! SRE × AI - ABEMA における AIOps -
tetsuya28
8
3.1k
DWH の限界突破! AI エージェント向け爆速リアルタイムデータ基盤 ClickHouse
jozono
1
450
Claude Code本は、どのように執筆チームで書かれたか
oikon48
1
370
触りたくないデータに、どう触るか AI時代のRBACアクセスガバナンス
ivry_presentationmaterials
0
230
Featured
See All Featured
Reality Check: Gamification 10 Years Later
codingconduct
0
2.3k
HDC tutorial
michielstock
2
960
KATA
mclloyd
PRO
35
16k
Writing Fast Ruby
sferik
630
63k
No one is an island. Learnings from fostering a developers community.
thoeni
21
3.9k
What’s in a name? Adding method to the madness
productmarketing
PRO
24
4.2k
Dominate Local Search Results - an insider guide to GBP, reviews, and Local SEO
greggifford
PRO
0
360
BBQ
matthewcrist
90
10k
Beyond borders and beyond the search box: How to win the global "messy middle" with AI-driven SEO
davidcarrasco
3
280
Optimising Largest Contentful Paint
csswizardry
37
4k
30 Presentation Tips
portentint
PRO
1
430
Money Talks: Using Revenue to Get Sh*t Done
nikkihalliwell
0
520
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?