بازسازی رجیستری ماژول کلودفلر ورکرز برای سازگاری با نود جی‌اس

کلودفلر رجیستری ماژول را در کامپوننت متن‌باز workerd بازنویسی کرده تا سریع‌تر، استانداردتر و سازگارتر با نود جی‌اس (Node.js) باشد.

تتیم تحریریه۱۲ دقیقه مطالعه۰ بازدید۹ روز پیش
بازسازی رجیستری ماژول کلودفلر ورکرز برای سازگاری با نود جی‌اس

ما رجیستری ماژول را در ورکرد (workerd)، کامپوننت متن‌باز هسته زمان اجرای ورکرز (Workers)، بازنویسی کرده‌ایم تا سریع‌تر، سازگارتر با استانداردها و هماهنگ‌تر با رجیستری ماژول نود جی‌اس (Node.js) باشد.

طی چند سال گذشته، ما پشتیبانی از APIهای زمان اجرای بیشتری را برای نود جی‌اس (Node.js) اضافه کرده‌ایم. زمان اجرای ورکرز (Workers) اکنون از هر API پایداری از نود جی‌اس (Node.js) که ممکن است بخواهید در یک زمینه بدون سرور (serverless) استفاده کنید، پشتیبانی می‌کند و این APIها اکنون به طور پیش‌فرض فعال هستند و به شما اجازه می‌دهند برنامه‌های بزرگ‌تر نود جی‌اس (Node.js) را روی کلودفلر (Cloudflare) مستقر کنید (اکنون تا ۶۴ مگابایت در همه پلن‌ها — ما محدودیت اندازه باندل فشرده‌شده را حذف کرده‌ایم).

اما سازگاری API به تنهایی کافی نیست: برنامه‌های نود جی‌اس (Node.js) همچنین به نحوه حل، بارگذاری و کش کردن ماژول‌ها توسط زمان اجرا وابسته هستند. فرمت‌های ESM، CommonJS و WebAssembly انواعی از ماژول‌ها هستند که می‌توانید در کد ورکر (Worker) خود وارد (import) کنید. سیستمی درون زمان اجرا که تمام این موارد را مدیریت می‌کند، رجیستری ماژول نامیده می‌شود.

شما می‌توانید همین امروز با فعال کردن پرچم سازگاری new_module_registry در ورکر (Worker) خود از آن استفاده کنید.

هنگامی که پرچم سازگاری new_module_registry را فعال می‌کنید:

  • عبارات import.meta.url، import.meta.main و import.meta.resolve() همگی کار می‌کنند.
  • مشخص‌کننده‌های ماژول (Module specifiers) به عنوان URLهای واقعی، از جمله رشته‌های پرس‌وجو (query strings) و قطعات (fragments)، تجزیه و حل می‌شوند.
  • موارد داخلی پیش‌فرض node: صرف نظر از نحوه دسترسی به آن‌ها، به همان نمونه ماژول تبدیل می‌شوند.
  • خصیصه‌های واردسازی (Import attributes) (مانند with { type: 'json' }) به درستی اعتبارسنجی می‌شوند.
  • تابع require() روی یک ماژول ES از قوانین require(esm) در نود جی‌اس (Node.js) پیروی می‌کند.
  • خطاها از کلاس‌ها و پیام‌های ثابتی استفاده می‌کنند، صرف نظر از اینکه کدام مسیر بارگذاری آن‌ها را ایجاد کرده است.
  • ماژول‌ها هنگام اولین واردسازی (به صورت ایستا یا پویا) به صورت تنبل (lazily) کامپایل می‌شوند.
  • ماژول‌های وب‌آمبلی (WebAssembly) از واردسازی فاز منبع (source phase imports) پشتیبانی می‌کنند.

برای بررسی عمیق و کامل نحوه تعامل این رجیستری ماژول جدید با APIهای ماژول V8، ما مستندات مرجعی را به ورکرد (workerd) اضافه کرده‌ایم که همه‌چیز را با جزئیات تفکیک می‌کند. اما برای بیشتر افرادی که روی ورکرز (Workers) ساخت‌وساز می‌کنند، مهم است که بفهمند این تغییرات چگونه سازگاری را بهبود می‌بخشند و به ساخت برنامه‌ها کمک می‌کنند. برای این کار، در بخش‌های زیر به هر یک از این تغییرات می‌پردازیم.

زمان اجرای ورکرز چگونه کدی را که به آن می‌دهید بارگذاری می‌کند

هنگامی که یک ورکر (Worker) را روی کلودفلر (Cloudflare) مستقر می‌کنید، رانگلر (wrangler) یا ویته (Vite) تمام کد ورکر شما را از فایل‌ها و وابستگی‌های مختلف به یک یا چند ماژول «باندل» می‌کند، که سپس هنگام اجرای دستور wrangler deploy در کلودفلر (Cloudflare) آپلود می‌شوند.

به طور پیش‌فرض، رانگلر (Wrangler) تقریباً تمام این کد را در یک اسکریپت ماژول واحد باندل می‌کند. این ابزار در پشت صحنه esbuild را اجرا می‌کند که واردسازی‌های نسبی و فراخوانی‌های require() را برای بیشتر وابستگی‌های npm در آن یک فایل پردازش و درون‌گذاری (inline) می‌کند. عبارات import و require() به عنوان بخشی از فرآیند با توابع معمولی جایگزین می‌شوند. تا زمانی که آن باندل به زمان اجرای ورکرز (workerd) می‌رسد، معمولاً گراف ماژول زیادی برای مدیریت توسط زمان اجرای ورکرز باقی نمانده است. بیشتر ماژول‌های مختلف در یک فایل باندل شده‌اند. ما دیده‌ایم که این اسکریپت‌ها تا چند صد هزار خط رشد کرده‌اند.

چرا لازم است قبل از آپلود کد سمت سرور به کلودفلر (Cloudflare)، چندین ماژول در یک فایل واحد باندل شوند؟ سال‌هاست که آپلود چندین ماژول و حتی ماژول‌هایی با انواعی متفاوت در زمان اجرای ورکرز از نظر فنی امکان‌پذیر بوده است. با این حال، زمان اجرا ماژول‌ها را به روشی که با سایر زمان‌های اجرا سازگار باشد، حل نکرده است. به عنوان مثال، اگر کد یا وابستگی‌های شما از import.meta.resolve() برای حل مسیر یک ماژول دیگر استفاده می‌کردند، آن کد با شکست مواجه می‌شد زیرا import.meta.resolve() پشتیبانی نمی‌شد.

هنگامی که از افزونه ویته کلودفلر (Cloudflare Vite plugin) استفاده می‌کنید، ویته ۸ (Vite 8) کد شما را به جای esbuild با استفاده از رول‌داون (Rolldown) باندل می‌کند. رول‌داون واردسازی‌ها و وابستگی‌های npm را حل می‌کند، CommonJS را در صورت نیاز به ESM تبدیل می‌کند، و یک ماژول ورودی به اضافه هر تکه (chunk) اضافی ایجاد شده از طریق تقسیم کد (code splitting)، مانند واردسازی‌های پویا (dynamic imports)، را تولید می‌کند. در نتیجه، زمان اجرای ورکرز به جای گراف کد منبع اصلی برنامه، یک گراف ماژول کوچکتر و تولید شده توسط بیلد را دریافت می‌کند.

پیاده‌سازی جدید رجیستری ماژول در زمان اجرای ورکرز، راه را برای باندلرهایی مانند رول‌داون باز می‌کند تا تبدیل‌های کمتری انجام دهند و بیشتر برای مدیریت حل ماژول به زمان اجرا اتکا کنند.

هنگامی که یک API از نود جی‌اس (Node.js) را در ورکر خود وارد می‌کنید، به طور پیش‌فرض در حال وارد کردن ماژولی هستید که در داخل workerd ساخته شده است. این ماژول به عنوان یک پلی‌فیل (polyfill) در کد شما باندل نشده است. ماژول‌های Wasm، متنی و باینری نیز به عنوان فایل‌های جداگانه به زمان اجرای ورکرز ارائه می‌شوند. به جای اینکه درون‌گذاری شوند، با مشخص‌کننده (specifier) ارجاع داده می‌شوند. و اگر با --no-bundle استقرار دهید، یا ابزار شما یک ورکر را مستقیماً به عنوان چندین ماژول آپلود کند، کل گراف ماژول دقیقاً همان‌طور که نوشته‌اید در زمان اجرا ظاهر می‌شود.

در تمام این موارد، چیزی باید یک مشخص‌کننده را بگیرد، بفهمد واقعاً به چه کدی اشاره دارد، آن را کامپایل کند و یک شیء ماژول که موتور V8 می‌تواند آن را پیوند داده و اجرا کند، به V8 تحویل دهد. در workerd، این وظیفه رجیستری ماژول است.

چرا یک پیاده‌سازی جدید؟

رجیستری اصلی مشخص‌کننده‌ها را به عنوان مسیرهای سبک فایل‌سیستم حل می‌کند، نه URLها. این ممکن است یک تمایز جزئی به نظر برسد، اما یک سری کارها را غیرممکن می‌کرد: هیچ روش تمیزی برای پیاده‌سازی import.meta.url وجود نداشت، واردسازی‌های نسبی از همان قوانین حل مشابه new URL() پیروی نمی‌کردند و پروتکل‌هایی مانند node: و cloudflare: به عنوان پیشوندهای رشته‌ای با شرایط خاص مدیریت می‌شدند نه به عنوان پروتکل‌های واقعی.

همچنین کل باندل ورکر شما را از پیش کامپایل می‌کند، صرف نظر از اینکه یک ماژول خاص هرگز وارد شود یا خیر، و یک کپی جداگانه و خصوصی از همه چیز را به ازای هر ایزوله V8 (V8 isolate) نگه می‌دارد. کلودفلر (Cloudflare) چندین نمونه ایزوله V8 از یک ورکر را برای توزیع بار روی هسته‌های CPU اجرا می‌کند، بنابراین در عمل به این معنی بود که دقیقا همان منبع بیش از یک بار کامپایل شود و چندین کپی از منبع در حافظه نگه داشته شود.

هیچ‌کدام از این‌ها واقعاً یک باگ نیست، اما تکامل دادن پیاده‌سازی را بدون ایجاد تغییرات ناسازگار (breaking changes) دشوار می‌کرد. رجیستری جدید با URLها به عنوان فرمت مشخص‌کننده شروع می‌شود و تنبلی (laziness) و اشتراک‌گذاری کش را به عنوان مواردی از روز اول طراحی در نظر می‌گیرد. پیاده‌سازی رجیستری موجود جایی نمی‌رود. در حال حاضر، ورکرهای مستقر شده به کار خود مثل همیشه ادامه خواهند داد.

import.meta

API جی‌اس import.meta اطلاعاتی درباره ماژول، مانند URL ماژول و اینکه آیا ماژول نقطه ورود اصلی است یا خیر، فراهم می‌کند:

این چیزی شبیه به file:///bundle/index.js, main: true را چاپ می‌کند.

مقدار import.meta.main فقط برای ماژولی که به عنوان نقطه ورود (entrypoint) ورکر شما پیکربندی شده است برابر با true است؛ هر ماژول دیگری مقدار false را دریافت می‌کند.

تابع import.meta.resolve() یک مشخص‌کننده را بر اساس ماژول فعلی بدون وارد کردن آن حل می‌کند:

این یک تبدیل رشته خالص است، درست مانند نود جی‌اس (Node.js) و مرورگرها: بررسی نمی‌کند که URL حل‌شده مربوط به یک ماژول واقعی باشد یا خیر، و برای مشخص‌کننده‌ای که اصلاً نتواند به عنوان یک URL تجزیه شود، به جای بازگرداندن null، یک خطای TypeError پرتاب می‌کند. یک جزئیات که اگر تا به حال به خروجی نگاه کرده باشید ارزش دانستن دارد این است که رمزگذاری درصدی (percent-encoding) را دقیقاً مانند new URL() نرمال‌سازی می‌کند، به این معنی که مسیرهایی مانند ./a/../b.js را جمع می‌کند، اما کاراکترهایی را که از قبل با درصد رمزگذاری شده‌اند، رمزگشایی نمی‌کند. عبارت import.meta.resolve('%66oo.js') به file:///bundle/%66oo.js حل می‌شود، نه file:///bundle/foo.js.

مشخص‌کننده‌ها URL هستند

واردسازی‌های نسبی اکنون دقیقاً مانند نحوه عملکرد new URL(specifier, base) حل می‌شوند، زیرا این دقیقا همان چیزی است که در پشت صحنه اتفاق می‌افتد. URLهای کامل نیز به عنوان مشخص‌کننده کار می‌کنند، نه فقط مسیرهای نسبی:

پیامد جالب‌تر این است که چه اتفاقی با رشته‌های پرس‌وجو (query strings) و قطعات (fragments) می‌افتد. طبق همان قوانین هویت ماژول که مرورگرها استفاده می‌کنند، یک مشخص‌کننده با یک رشته پرس‌وجو یا قطعه متفاوت، حتی زمانی که به منبع زیرین یکسانی اشاره می‌کند، به عنوان یک نمونه ماژول کاملاً متمایز در نظر گرفته می‌شود:

عبارات ./counter.js?a و ./counter.js?b همان منبع را بارگذاری می‌کنند، اما به طور جداگانه ارزیابی می‌شوند، هر کدام import.meta.url خود را دریافت می‌کنند و هر کدام کپی خود را از هر حالت سطح بالایی (top-level state) دارند. وارد کردن دوباره همان مشخص‌کننده با همان رشته پرس‌وجو همچنان همان نمونه را به شما بازمی‌گرداند، بنابراین این روشی برای مجبور کردن به ارزیابی مجدد در هر واردسازی نیست.

خصیصه‌های واردسازی به درستی اعتبارسنجی می‌شوند

پیاده‌سازی اصلی رجیستری ماژول، خصیصه‌های واردسازی را بر خلاف مشخصات به طور صامت نادیده می‌گیرد. انتظار می‌رود که پیاده‌سازی‌ها زمانی که از هر خصیصه واردسازی که آن را درک نمی‌کنند استفاده می‌شود، یک استثناء پرتاب کنند.

نوع json تنها نوع خصیصه واردسازی است که در حال حاضر فعال است، زیرا تنها پیشنهادی از پروپوزال‌های مرتبط TC39 است که به مرحله ۴ رسیده است. انواع text و bytes شناخته می‌شوند، زیرا پروپوزال‌های Import Text و Import Bytes را دنبال می‌کنند، اما به جای اینکه به طور صامت نادیده گرفته شوند یا به عنوان سینتکس پشتیبانی‌نشده رفتار شوند، با یک خطای خاص رد می‌شوند:

هر کلید خصیصه‌ای غیر از type اکنون یک خطای جدی است، به جای اینکه نادیده گرفته شود:

و اگر نوعی که مشخص می‌کنید با آنچه ماژول در واقعیت است مطابقت نداشته باشد:

تابع require(esm) از قوانین نود جی‌اس پیروی می‌کند

اگر چیزی را با require() فراخوانی کنید که مشخص شود یک ماژول ES است، چه مستقیماً در داخل یک ماژول CommonJS و چه از طریق require('node:module').createRequire()، رجیستری از رفتار require(esm) در نود جی‌اس (Node.js) پیروی می‌کند:

  • اگر ماژول دارای یک صادرات با نام رشته‌ای به نام 'module.exports' باشد (مکانیزم واقعی نود جی‌اس برای اجازه دادن به یک ماژول ES برای کنترل آنچه require() می‌بیند)، آن مقدار بازگردانده می‌شود.
  • در غیر این صورت، تابع require() شیء فضای نام (namespace object) ماژول را بازمی‌گرداند.
  • تنها استثناء، موارد داخلی node: متعلق به خود workerd است. آن‌ها به عنوان ماژول‌های ES پیاده‌سازی شده‌اند که یک API به سبک CommonJS را در یک صادرات پیش‌فرض (default export) می‌پیچند، بنابراین فراخوانی require روی یکی از آن‌ها، مستقیماً همان صادرات پیش‌فرض را بازمی‌گرداند. عبارت require('node:buffer').Buffer همان‌طور که انتظار دارید رفتار می‌کند؛ شما یک شیء فضای نام با یک .default که نیاز به باز کردن بسته‌بندی داشته باشد دریافت نمی‌کنید.

یک محدودیت همراه با این وجود دارد: اگر ماژول مورد نظر شما برای require کردن، یا هر چیزی در گراف ماژول آن، دارای یک await سطح بالا باشد، تابع require() به جای مسدود کردن یا تحویل دادن چیزی نیمه‌کاره، خطا پرتاب می‌کند:

این با محدودیت ERR_REQUIRE_ASYNC_MODULE خود نود جی‌اس (Node.js) مطابقت دارد: require() باید به صورت همگام (synchronously) بازگردد و هیچ مقدار منطقی برای تحویل دادن به ماژولی که ارزیابی آن هنوز به پایان نرسیده است وجود ندارد. به جای آن از import() برای هر کار ناهمگامی استفاده کنید. این بررسی صرف نظر از ترتیب واردسازی نیز برقرار است: یک ماژول صرفاً به این دلیل که چیزی قبلاً آن را با import() وارد کرده و کاملاً ارزیابی کرده است، قابل require() شدن نمی‌شود.

اگر در حال فراخوانی خروجی از یک باندلر هستید که مقدم بر پشتیبانی نود جی‌اس (Node.js) از require(esm) است و یک صادرات truthy با نام __cjsUnwrapDefault را به عنوان یک نشانگر تنظیم می‌کند، آن علامت بر هر دو قانون بالا اولویت دارد و صادرات پیش‌فرض را بازمی‌گرداند. این کار صرفاً برای این است که باندل‌های از پیش ساخته شده موجود همچنان کار کنند.

خطاها ثابت هستند و از کلاس درستی استفاده می‌کنند

صرف نظر از اینکه حل ماژول از طریق یک import ایستا، یک import() پویا، یا require() با شکست مواجه شود، شما همان کلاس خطا را با همان شکل پیام دریافت می‌کنید:

عبارت "Module not found" یک خطای ساده است (Error)، زیرا این یک شکست در یافتن چیزی است تا یک مشکل با مقداری که پاس داده‌اید. مشخص‌کننده‌ای که اصلاً نتواند به عنوان یک URL تجزیه شود، یک خطای TypeError است که با خطای ERR_INVALID_MODULE_SPECIFIER خود نود جی‌اس (Node.js) مطابقت دارد. یک وابستگی دایره‌ای که موتور V8 نمی‌تواند آن را باز کند نیز یک خطای ساده است و هرگز یک TypeError نیست. این موضوع عمدتاً زمانی اهمیت پیدا می‌کند که شما چیزی را بر اساس import() پویا می‌سازید، مانند لادر (loader) خودتان یا یک پوشش تلاش مجدد (retry wrapper)، زیرا اکنون می‌توانید بدون توجه به مسیری که بارگذاری را فعال کرده است، با اطمینان روی کلاس خطا یا پیام انشعاب ایجاد کنید.

واردسازی فاز منبع وب‌آمبلی (WebAssembly source phase imports)

اکنون می‌توانید فرم کامپایل‌شده اما实例化‌نشده (instantiated) یک ماژول وب‌آمبلی (WebAssembly) را مستقیماً با استفاده از واردسازی فاز منبع (source phase imports) وارد کنید:

یا به صورت پویا:

در هر صورت شما مستقیماً یک WebAssembly.Module دریافت می‌کنید، به جای اینکه ماژول را به طور عادی وارد کنید و آن را از صادرات پیش‌فرض (default) بیرون بکشید. از آنجایی که واردسازی فاز منبع یک ویژگی جدید زبان است، در حال حاضر این کار فقط برای وب‌آمبلی (WebAssembly) کار می‌کند؛ امتحان کردن آن روی هر نوع ماژول دیگری یک خطای SyntaxError پرتاب می‌کند که با رفتار نود جی‌اس (Node.js) و سایر زمان‌های اجرا مطابقت دارد.

مراحل بعدی

آن را امتحان کنید! پرچم سازگاری new_module_registry را به ورکر خود اضافه کنید:

این پرچم هنوز تاریخ پیش‌فرضی ندارد، بنابراین به طور خودکار برای ورکر شما، چه قدیمی و چه جدید، صرف نظر از تاریخ سازگاری که استفاده می‌کند، روشن نخواهد شد. شما باید به صراحت پرچم را اضافه کنید.

ما مشتاقانه منتظر بازخوردهای شما هستیم. workerd یک پروژه متن‌باز است. اگر با رفتاری مواجه شدید که شبیه به یک رگرسیون (regression) است تا یکی از تغییرات توصیف شده در اینجا، لطفاً آن را در مخزن workerd (workerd repository) ثبت کنید.


منبع: blog.cloudflare.com

نظرات۰

برای نوشتن نظر، وارد حساب خود شوید.

ورود / ثبت‌نام

هنوز نظری ثبت نشده — اولین نفری باش که نظر می‌دهد.