Skip to content

TanStack Start هو إطار عمل كامل الميزات مدعوم بـ TanStack Router. يدعم تقديم المستندات الكاملة، والبث، ووظائف الخادم، والتجميع والمزيد، مدعومًا بـ TanStack Router و Vite.


إنشاء تطبيق TanStack Start جديد

استخدم CLI التفاعلي لإنشاء تطبيق TanStack Start جديد.

sh
bun create @tanstack/start@latest my-tanstack-app

بدء خادم التطوير

انتقل إلى دليل المشروع وشغل خادم التطوير باستخدام Bun.

sh
cd my-tanstack-app
bun --bun run dev

يبدأ هذا خادم تطوير Vite مع Bun.

تحديث النصوص في package.json

عدل حقل scripts في package.json الخاص بك عن طريق إضافة البادئة bun --bun لأوامر Vite CLI. يضمن هذا أن Bun ينفذ Vite CLI للمهام الشائعة مثل dev و build و preview.

json
{
  "scripts": {
    "dev": "bun --bun vite dev", 
    "build": "bun --bun vite build", 
    "serve": "bun --bun vite preview"
  }
}

الاستضافة

لاستضافة تطبيق TanStack Start الخاص بك، يمكنك استخدام Nitro أو خادم Bun مخصص لنشر الإنتاج.

Nitro

إضافة Nitro إلى مشروعك

أضف Nitro إلى مشروعك. تتيح لك هذه الأداة نشر تطبيق TanStack Start الخاص بك على منصات مختلفة.

sh
bun add nitro

تحديث ملف vite.config.ts

قم بتحديث ملف vite.config.ts الخاص بك لتضمين الإضافات اللازمة لـ TanStack Start مع Bun.

ts
// other imports...
import { nitro } from "nitro/vite"; 

const config = defineConfig({
  plugins: [
    tanstackStart(),
    nitro({ preset: "bun" }), 
    // other plugins...
  ],
});

export default config;

NOTE

الإعداد المسبق `bun` اختياري، لكنه يهيئ مخرجات البناء خصيصًا لوقت تشغيل Bun.

تحديث أمر البدء

تأكد من وجود نصوص build و start في ملف package.json الخاص بك:

json
  {
    "scripts": {
      "build": "bun --bun vite build", 
      // يتم إنشاء ملفات .output بواسطة Nitro عند تشغيل `bun run build`.
      // غير ضروري عند النشر على Vercel.
      "start": "bun run .output/server/index.mjs"
    }
  }

NOTE

أنت لست بحاجة إلى نص `start` مخصص عند النشر على Vercel.

نشر تطبيقك

راجع أحد أدلتنا لنشر تطبيقك على موفر استضافة.

NOTE

عند النشر على Vercel، يمكنك إما إضافة `"bunVersion": "1.x"` إلى ملف `vercel.json` الخاص بك، أو إضافته إلى تكوين `nitro` في ملف `vite.config.ts` الخاص بك:

تحذير

لا تستخدم الإعداد المسبق bun Nitro عند النشر على Vercel.

ts
export default defineConfig({
  plugins: [
    tanstackStart(),
    nitro({
      preset: "bun", 
      vercel: { 
        functions: { 
          runtime: "bun1.x", 
        }, 
    }, 
    }),
  ],
});

خادم مخصص

NOTE

يعتمد تنفيذ الخادم المخصص هذا على [قالب Bun الخاص بـ TanStack](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts). يوفر تحكمًا دقيقًا في تقديم الأصول الثابتة، بما في ذلك إدارة ذاكرة قابلة للتكوين تقوم بتحميل الملفات الصغيرة مسبقًا في الذاكرة للخدمة السريعة مع خدمة الملفات الأكبر حسب الطلب. هذا النهج مفيد عندما تحتاج إلى تحكم دقيق في استخدام الموارد وسلوك تحميل الأصول في نشر الإنتاج.

إنشاء خادم الإنتاج

أنشئ ملف server.ts في جذر مشروعك مع تنفيذ الخادم المخصص التالي:

ts
/**
* خادم إنتاج TanStack Start مع Bun
*
* خادم إنتاج عالي الأداء لتطبيقات TanStack Start الذي
* ينفذ تحميلًا ذكيًا للأصول الثابتة مع إدارة ذاكرة قابلة للتكوين.
*
* الميزات:
* - استراتيجية تحميل هجينة (تحميل مسبق للملفات الصغيرة، خدمة الملفات الكبيرة حسب الطلب)
* - تصفية ملفات قابلة للتكوين مع أنماط تضمين/استبعاد
* - توليد استجابة فعال للذاكرة
* - رؤوس تخزين جاهزة للإنتاج
*
* متغيرات البيئة:
*
* PORT (رقم)
*   - رقم منفذ الخادم
*   - الافتراضي: 3000
*
* ASSET_PRELOAD_MAX_SIZE (رقم)
*   - الحد الأقصى لحجم الملف بالبايت للتحميل المسبق في الذاكرة
*   - الملفات الأكبر من هذا سيتم خدمتها عند الطلب من القرص
*   - الافتراضي: 5242880 (5 ميجابايت)
*   - مثال: ASSET_PRELOAD_MAX_SIZE=5242880 (5 ميجابايت)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (سلسلة)
*   - قائمة مفصولة بفواصل من أنماط glob للتضمين
*   - إذا تم تحديدها، فقط الملفات المطابقة مؤهلة للتحميل المسبق
*   - الأنماط مطابقة لأسماء الملفات فقط، وليس المسارات الكاملة
*   - مثال: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (سلسلة)
*   - قائمة مفصولة بفواصل من أنماط glob للاستبعاد
*   - تطبق بعد أنماط التضمين
*   - الأنماط مطابقة لأسماء الملفات فقط، وليس المسارات الكاملة
*   - مثال: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (منطقي)
*   - تمكين التسجيل المفصل للملفات المحملة والمتخطية
*   - الافتراضي: false
*   - اضبط إلى "true" لتمكين الإخراج المفصل
*
* ASSET_PRELOAD_ENABLE_ETAG (منطقي)
*   - تمكين توليد ETag للأصول المحملة مسبقًا
*   - الافتراضي: true
*   - اضبط إلى "false" لتعطيل دعم ETag
*
* ASSET_PRELOAD_ENABLE_GZIP (منطقي)
*   - تمكين ضغط Gzip للأصول المؤهلة
*   - الافتراضي: true
*   - اضبط إلى "false" لتعطيل ضغط Gzip
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (رقم)
*   - الحد الأدنى لحجم الملف بالبايت المطلوب لضغط Gzip
*   - الملفات الأصغر من هذا لن يتم ضغطها
*   - الافتراضي: 1024 (1 كيلوبايت)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (سلسلة)
*   - قائمة مفصولة بفواصل من أنواع MIME المؤهلة لضغط Gzip
*   - يدعم المطابقة الجزئية للأنواع المنتهية بـ "/"
*   - الافتراضي: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* الاستخدام:
*   bun run server.ts
*/

import path from 'node:path'

// التكوين
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'

// أدوات تسجيل للإخراج المهني
const log = {
  info: (message: string) => {
    console.log(`[INFO] ${message}`)
  },
  success: (message: string) => {
    console.log(`[SUCCESS] ${message}`)
  },
  warning: (message: string) => {
    console.log(`[WARNING] ${message}`)
  },
  error: (message: string) => {
    console.log(`[ERROR] ${message}`)
  },
  header: (message: string) => {
    console.log(`\n${message}\n`)
  },
}

// تكوين التحميل المسبق من متغيرات البيئة
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5 ميجابايت افتراضي
)

// تحليل أنماط التضمين المفصولة بفواصل (بدون افتراضيات)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// تحليل أنماط الاستبعاد المفصولة بفواصل (بدون افتراضيات)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// علامة التسجيل المفصل
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'

// ميزة ETag الاختيارية
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'

// ميزة Gzip الاختيارية
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 1 كيلوبايت
const GZIP_TYPES = (
  process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
  'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
  .split(',')
  .map((v) => v.trim())
  .filter(Boolean)

/**
* تحويل نمط glob بسيط إلى تعبير عادي
* يدعم * wildcard لمطابقة أي أحرف
*/
function convertGlobToRegExp(globPattern: string): RegExp {
  // هروب أحرف regex الخاصة باستثناء *، ثم استبدال * بـ .*
  const escapedPattern = globPattern
    .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
    .replace(/\*/g, '.*')
  return new RegExp(`^${escapedPattern}$`, 'i')
}

/**
* حساب ETag لمخزن بيانات معين
*/
function computeEtag(data: Uint8Array): string {
  const hash = Bun.hash(data)
  return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}

/**
* بيانات وصفية للأصول الثابتة المحملة مسبقًا
*/
interface AssetMetadata {
  route: string
  size: number
  type: string
}

/**
* أصل في الذاكرة مع دعم ETag و Gzip
*/
interface InMemoryAsset {
  raw: Uint8Array
  gz?: Uint8Array
  etag?: string
  type: string
  immutable: boolean
  size: number
}

/**
* نتيجة عملية التحميل المسبق للأصول الثابتة
*/
interface PreloadResult {
  routes: Record<string, (req: Request) => Response | Promise<Response>>
  loaded: AssetMetadata[]
  skipped: AssetMetadata[]
}

/**
* التحقق مما إذا كان الملف مؤهلاً للتحميل المسبق بناءً على الأنماط المكونة
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
  const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath

  // إذا تم تحديد أنماط التضمين، يجب أن يطابق الملف واحدًا على الأقل
  if (INCLUDE_PATTERNS.length > 0) {
    if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
      return false
    }
  }

  // إذا تم تحديد أنماط الاستبعاد، يجب ألا يطابق الملف أيًا منها
  if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
    return false
  }

  return true
}

/**
* التحقق مما إذا كان نوع MIME قابل للضغط
*/
function isMimeTypeCompressible(mimeType: string): boolean {
  return GZIP_TYPES.some((type) =>
    type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
  )
}

/**
* ضغط البيانات بشكل مشروط بناءً على الحجم ونوع MIME
*/
function compressDataIfAppropriate(
  data: Uint8Array,
  mimeType: string,
): Uint8Array | undefined {
  if (!ENABLE_GZIP) return undefined
  if (data.byteLength < GZIP_MIN_BYTES) return undefined
  if (!isMimeTypeCompressible(mimeType)) return undefined
  try {
    return Bun.gzipSync(data.buffer as ArrayBuffer)
  } catch {
    return undefined
  }
}

/**
* إنشاء دالة معالج الاستجابة مع دعم ETag و Gzip
*/
function createResponseHandler(
  asset: InMemoryAsset,
): (req: Request) => Response {
  return (req: Request) => {
    const headers: Record<string, string> = {
      'Content-Type': asset.type,
      'Cache-Control': asset.immutable
        ? 'public, max-age=31536000, immutable'
        : 'public, max-age=3600',
    }

    if (ENABLE_ETAG && asset.etag) {
      const ifNone = req.headers.get('if-none-match')
      if (ifNone && ifNone === asset.etag) {
        return new Response(null, {
          status: 304,
          headers: { ETag: asset.etag },
        })
      }
      headers.ETag = asset.etag
    }

    if (
      ENABLE_GZIP &&
      asset.gz &&
      req.headers.get('accept-encoding')?.includes('gzip')
    ) {
      headers['Content-Encoding'] = 'gzip'
      headers['Content-Length'] = String(asset.gz.byteLength)
      const gzCopy = new Uint8Array(asset.gz)
      return new Response(gzCopy, { status: 200, headers })
    }

    headers['Content-Length'] = String(asset.raw.byteLength)
    const rawCopy = new Uint8Array(asset.raw)
    return new Response(rawCopy, { status: 200, headers })
  }
}

/**
* إنشاء نمط glob مركب من أنماط التضمين
*/
function createCompositeGlobPattern(): Bun.Glob {
  const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean)
  if (raw.length === 0) return new Bun.Glob('**/*')
  if (raw.length === 1) return new Bun.Glob(raw[0])
  return new Bun.Glob(`{${raw.join(',')}}`)
}

/**
* تهيئة المسارات الثابتة مع استراتيجية تحميل مسبق ذكية
* يتم تحميل الملفات الصغيرة في الذاكرة، ويتم خدمة الملفات الكبيرة عند الطلب
*/
async function initializeStaticRoutes(
  clientDirectory: string,
): Promise<PreloadResult> {
  const routes: Record<string, (req: Request) => Response | Promise<Response>> =
    {}
  const loaded: AssetMetadata[] = []
  const skipped: AssetMetadata[] = []

  log.info(`تحميل الأصول الثابتة من ${clientDirectory}...`)
  if (VERBOSE) {
    console.log(
      `الحد الأقصى للتحميل المسبق: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} ميجابايت`,
    )
    if (INCLUDE_PATTERNS.length > 0) {
      console.log(
        `أنماط التضمين: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
      )
    }
    if (EXCLUDE_PATTERNS.length > 0) {
      console.log(
        `أنماط الاستبعاد: ${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
      )
    }
  }

  let totalPreloadedBytes = 0

  try {
    const glob = createCompositeGlobPattern()
    for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
      const filepath = path.join(clientDirectory, relativePath)
      const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`

      try {
        // الحصول على بيانات وصفية للملف
        const file = Bun.file(filepath)

        // تخطي إذا كان الملف غير موجود أو فارغ
        if (!(await file.exists()) || file.size === 0) {
          continue
        }

        const metadata: AssetMetadata = {
          route,
          size: file.size,
          type: file.type || 'application/octet-stream',
        }

        // تحديد ما إذا كان الملف يجب تحميله مسبقًا
        const matchesPattern = isFileEligibleForPreloading(relativePath)
        const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES

        if (matchesPattern && withinSizeLimit) {
          // تحميل الملفات الصغيرة مسبقًا في الذاكرة مع دعم ETag و Gzip
          const bytes = new Uint8Array(await file.arrayBuffer())
          const gz = compressDataIfAppropriate(bytes, metadata.type)
          const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
          const asset: InMemoryAsset = {
            raw: bytes,
            gz,
            etag,
            type: metadata.type,
            immutable: true,
            size: bytes.byteLength,
          }
          routes[route] = createResponseHandler(asset)

          loaded.push({ ...metadata, size: bytes.byteLength })
          totalPreloadedBytes += bytes.byteLength
        } else {
          // خدمة الملفات الكبيرة أو المصفاة عند الطلب
          routes[route] = () => {
            const fileOnDemand = Bun.file(filepath)
            return new Response(fileOnDemand, {
              headers: {
                'Content-Type': metadata.type,
                'Cache-Control': 'public, max-age=3600',
              },
            })
          }

          skipped.push(metadata)
        }
      } catch (error: unknown) {
        if (error instanceof Error && error.name !== 'EISDIR') {
          log.error(`فشل تحميل ${filepath}: ${error.message}`)
        }
      }
    }

    // إظهار نظرة عامة مفصلة للملفات فقط عند تمكين الوضع المفصل
    if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
      const allFiles = [...loaded, ...skipped].sort((a, b) =>
        a.route.localeCompare(b.route),
      )

      // حساب الحد الأقصى لطول المسار للمحاذاة
      const maxPathLength = Math.min(
        Math.max(...allFiles.map((f) => f.route.length)),
        60,
      )

      // تنسيق حجم الملف مع KB وحجم gzip الفعلي
      const formatFileSize = (bytes: number, gzBytes?: number) => {
        const kb = bytes / 1024
        const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)

        if (gzBytes !== undefined) {
          const gzKb = gzBytes / 1024
          const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
          return {
            size: sizeStr,
            gzip: gzStr,
          }
        }

        // تقدير gzip تقريبي (عادةً ضغط 30-70٪) إذا لم تكن هناك بيانات gzip فعلية
        const gzipKb = kb * 0.35
        return {
          size: sizeStr,
          gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
        }
      }

      if (loaded.length > 0) {
        console.log('\n📁 تم التحميل المسبق في الذاكرة:')
        console.log(
          'المسار                                          │    الحجم │ حجم Gzip',
        )
        loaded
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }

      if (skipped.length > 0) {
        console.log('\n💾 تم الخدمة عند الطلب:')
        console.log(
          'المسار                                          │    الحجم │ حجم Gzip',
        )
        skipped
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }
    }

    // إظهار معلومات مفصلة مفصلة إذا تم تمكينها
    if (VERBOSE) {
      if (loaded.length > 0 || skipped.length > 0) {
        const allFiles = [...loaded, ...skipped].sort((a, b) =>
          a.route.localeCompare(b.route),
        )
        console.log('\n📊 معلومات تفصيلية عن الملفات:')
        console.log(
          'الحالة       │ المسار                            │ نوع MIME                    │ السبب',
        )
        allFiles.forEach((file) => {
          const isPreloaded = loaded.includes(file)
          const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
          const reason =
            !isPreloaded && file.size > MAX_PRELOAD_BYTES
              ? 'كبير جدًا'
              : !isPreloaded
                ? 'تمت التصفية'
                : 'تم التحميل المسبق'
          const route =
            file.route.length > 30
              ? file.route.substring(0, 27) + '...'
              : file.route
          console.log(
            `${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`,
          )
        })
      } else {
        console.log('\n📊 لم يتم العثور على ملفات للعرض')
      }
    }

    // إظهار ملخص مفصل بعد قائمة الملفات
    console.log() // سطر فارغ للفصل
    if (loaded.length > 0) {
      log.success(
        `تم التحميل المسبق ${String(loaded.length)} ملفات (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} ميجابايت) في الذاكرة`,
      )
    } else {
      log.info('لم يتم تحميل أي ملفات مسبقًا في الذاكرة')
    }

    if (skipped.length > 0) {
      const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
      const filtered = skipped.length - tooLarge
      log.info(
        `${String(skipped.length)} ملفات سيتم خدمتها عند الطلب (${String(tooLarge)} كبيرة جدًا، ${String(filtered)} تمت التصفية)`,
      )
    }
  } catch (error) {
    log.error(
      `فشل تحميل الملفات الثابتة من ${clientDirectory}: ${String(error)}`,
    )
  }

  return { routes, loaded, skipped }
}

/**
* تهيئة الخادم
*/
async function initializeServer() {
  log.header('بدء خادم الإنتاج')

  // تحميل معالج خادم TanStack Start
  let handler: { fetch: (request: Request) => Response | Promise<Response> }
  try {
    const serverModule = (await import(SERVER_ENTRY_POINT)) as {
      default: { fetch: (request: Request) => Response | Promise<Response> }
    }
    handler = serverModule.default
    log.success('تم تهيئة معالج تطبيق TanStack Start')
  } catch (error) {
    log.error(`فشل تحميل معالج الخادم: ${String(error)}`)
    process.exit(1)
  }

  // بناء المسارات الثابتة مع التحميل المسبق الذكي
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

  // إنشاء خادم Bun
  const server = Bun.serve({
    port: SERVER_PORT,

    routes: {
      // خدمة الأصول الثابتة (محمل مسبقًا أو عند الطلب)
      ...routes,

      // العودة إلى معالج TanStack Start لجميع المسارات الأخرى
      '/*': (req: Request) => {
        try {
          return handler.fetch(req)
        } catch (error) {
          log.error(`خطأ معالج الخادم: ${String(error)}`)
          return new Response('خطأ داخلي في الخادم', { status: 500 })
        }
      },
    },

    // معالج أخطاء عام
    error(error) {
      log.error(
        `خطأ خادم غير معالج: ${error instanceof Error ? error.message : String(error)}`,
      )
      return new Response('خطأ داخلي في الخادم', { status: 500 })
    },
  })

  log.success(`الخادم يستمع على http://localhost:${String(server.port)}`)
}

// تهيئة الخادم
initializeServer().catch((error: unknown) => {
  log.error(`فشل بدء الخادم: ${String(error)}`)
  process.exit(1)
})

تحديث نصوص package.json

أضف نص start لتشغيل الخادم المخصص:

json
{
  "scripts": {
    "build": "bun --bun vite build",
    "start": "bun run server.ts"
  }
}

البناء والتشغيل

قم ببناء تطبيقك وبدء الخادم:

sh
bun run build
bun run start

سيبدأ الخادم على المنفذ 3000 بشكل افتراضي (قابل للتكوين عبر متغير البيئة PORT).


القوالب


→ راجع الوثائق الرسمية لـ TanStack Start للحصول على مزيد من المعلومات حول الاستضافة.

Bun بواسطة www.bunjs.com.cn تحرير