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
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
200
知って得する@cloudflare_vite-pluginのあれこれ
chimame
2
680
Boost Your Web Performance with Hyperdrive
chimame
1
580
私がエッジを使う理由
chimame
10
4.2k
GraphQL Server on Edge after that
chimame
1
1.9k
Accelerating App Dev with Cloudflare Workers
chimame
1
510
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
手を動かして実感する、Kiro が変える開発体験
inariku
0
190
[Kiro Meetup #7] Kiro Crew Dive Deep
konippi
0
260
おい、エージェントを使って終わらせろ
nwiizo
3
870
ソフトウェアDNAとクラウドエージェントのススメ
cloudace
0
120
フルカイテン株式会社 エンジニア向け採用資料
fullkaiten
0
12k
アクセスキーこわい やめかたと漏らさない工夫
sassssan68
1
650
AgentCore Runtime上にAgentic Coding基盤を構築・展開する際の設計ポイントと限界点 / Design considerations and limitations when building an agentic coding platform on AgentCore Runtime
har1101
5
690
AIエージェントを最高のパートナーに育てる方法|評価と判断軸を育てる5つのステップ
koichiaoki
1
160
Von-Neumann Machines, Dreams & The Futures of IT
ufried
0
120
株式会社シーエーシー エンジニア向け会社紹介資料
cac
0
57k
「今盗んで、後で解く」に備える ― AWSのポスト量子暗号入門
yama3133
1
110
AIに賢く動いてもらうためのコンテキスト〜Snowflake女子会 vol.8
snowwmn0824
0
160
Featured
See All Featured
How to audit for AI Accessibility on your Front & Back End
davetheseo
0
550
Leo the Paperboy
mayatellez
10
2.3k
Evolving SEO for Evolving Search Engines
ryanjones
0
300
The Illustrated Children's Guide to Kubernetes
chrisshort
51
53k
Utilizing Notion as your number one productivity tool
mfonobong
4
590
Money Talks: Using Revenue to Get Sh*t Done
nikkihalliwell
0
500
How GitHub (no longer) Works
holman
316
150k
WENDY [Excerpt]
tessaabrams
14
39k
Abbi's Birthday
coloredviolet
4
10k
Marketing Yourself as an Engineer | Alaka | Gurzu
gurzu
0
310
Designing for humans not robots
tammielis
254
26k
AI in Enterprises - Java and Open Source to the Rescue
ivargrimstad
0
1.5k
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?