|
| 1 | +--- |
| 2 | +description: 'Sajikan berkas dari S3, R2, atau object storage apa pun di Deserve lewat hook staticHandler atau route handler.' |
| 3 | +--- |
| 4 | + |
| 5 | +# Object Storage |
| 6 | + |
| 7 | +[Static serving](/id/static-file/basic) bawaan membaca dari filesystem lokal, jadi `router.static()` saja tidak bisa menjangkau bucket di S3, Cloudflare R2, atau Google Cloud Storage. Jembatannya adalah opsi [`staticHandler`](/id/getting-started/routes-configuration#statichandler), sebuah hook yang mempertahankan route static yang familiar sambil menukar pembacaan berkas dengan fetch ke object storage. Route tetap terdaftar lewat `router.static()`, dan handler menjawab tiap request dari bucket alih-alih dari disk. |
| 8 | + |
| 9 | +## Kenapa Hook dan Bukan Path |
| 10 | + |
| 11 | +Opsi `path` pada [static serving](/id/static-file/basic#path) memetakan prefix URL ke folder yang bisa diresolusi `Deno.stat` dan `Deno.realPath`, yang merupakan kontrak disk lokal. Object storage tidak punya path nyata di disk, jadi pemeriksaan traversal yang aman dan streaming lewat file handle tidak berlaku. Hook `staticHandler` menyerahkan seluruh langkah serve, jadi bucket menjadi sumber kebenaran sementara permukaan route tetap sama. |
| 12 | + |
| 13 | +## Menyajikan Dari Bucket |
| 14 | + |
| 15 | +Sebagian besar object store mengekspos endpoint HTTPS per objek, jadi `fetch` ke `${endpoint}/${key}` menarik byte-nya. Handler memotong prefix URL dari `ctx.pathname` untuk memulihkan kunci objek, lalu mengalirkan body response langsung lewat [`ctx.send.stream`](/id/response/stream): |
| 16 | + |
| 17 | +```typescript twoslash |
| 18 | +import { Router, type Context, type ServeOptions } from '@neabyte/deserve' |
| 19 | + |
| 20 | +// Endpoint dasar bucket |
| 21 | +const endpoint = 'https://my-bucket.s3.amazonaws.com' |
| 22 | + |
| 23 | +const router = new Router({ |
| 24 | + routesDir: 'routes', |
| 25 | + staticHandler: { |
| 26 | + // Sajikan tiap objek dari bucket |
| 27 | + async serve(ctx: Context, options: ServeOptions, urlPath: string) { |
| 28 | + // Pulihkan kunci objek dari path |
| 29 | + const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') |
| 30 | + const object = await fetch(`${endpoint}/${key}`) |
| 31 | + if (!object.ok || !object.body) { |
| 32 | + return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) |
| 33 | + } |
| 34 | + // Alirkan body bucket ke klien |
| 35 | + const contentType = object.headers.get('content-type') ?? 'application/octet-stream' |
| 36 | + return ctx.send.stream(object.body, undefined, contentType) |
| 37 | + } |
| 38 | + } |
| 39 | +}) |
| 40 | + |
| 41 | +// Daftarkan route yang dipenuhi handler |
| 42 | +router.static( |
| 43 | + '/assets', |
| 44 | + { |
| 45 | + path: 's3' |
| 46 | + } |
| 47 | +) |
| 48 | + |
| 49 | +await router.serve(8000) |
| 50 | +``` |
| 51 | + |
| 52 | +Nilai `path` tetap harus diset pada [`router.static()`](/id/static-file/basic) karena wajib, namun handler mengabaikannya di sini karena bucket menggantikan folder lokal. Request ke `/assets/logo.png` menjadi fetch untuk kunci `logo.png`. |
| 53 | + |
| 54 | +## Meneruskan Byte Range |
| 55 | + |
| 56 | +Static serving menjawab [byte range](/id/static-file/basic#permintaan-byte-range) sendiri, tapi handler kustom kini memegang tugas itu. Meneruskan header `Range` yang masuk ke bucket membiarkan store mengembalikan konten parsial, dan meneruskan kembali status serta header range menjaga penggeser video atau unduhan yang bisa dilanjutkan tetap bekerja: |
| 57 | + |
| 58 | +```typescript twoslash |
| 59 | +import type { Context, ServeOptions } from '@neabyte/deserve' |
| 60 | +declare const endpoint: string |
| 61 | +// ---cut--- |
| 62 | +async function serve(ctx: Context, options: ServeOptions, urlPath: string) { |
| 63 | + const key = ctx.pathname.slice(urlPath.length).replace(/^\//, '') |
| 64 | + const range = ctx.header('range') |
| 65 | + |
| 66 | + // Teruskan header Range bila ada |
| 67 | + const object = await fetch(`${endpoint}/${key}`, { |
| 68 | + headers: range ? { Range: range } : {} |
| 69 | + }) |
| 70 | + if (!object.ok || !object.body) { |
| 71 | + return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) |
| 72 | + } |
| 73 | + |
| 74 | + // Cerminkan header range ke klien |
| 75 | + const contentType = object.headers.get('content-type') ?? 'application/octet-stream' |
| 76 | + const contentRange = object.headers.get('content-range') |
| 77 | + if (contentRange) { |
| 78 | + ctx.setHeader('Content-Range', contentRange) |
| 79 | + ctx.setHeader('Accept-Ranges', 'bytes') |
| 80 | + } |
| 81 | + return ctx.send.custom(object.body, { |
| 82 | + status: object.status, |
| 83 | + headers: { |
| 84 | + 'Content-Type': contentType |
| 85 | + } |
| 86 | + }) |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +Sebuah `206 Partial Content` dari bucket mengalir balik tanpa berubah, karena `ctx.send.custom` mempertahankan status yang dipilih bucket. |
| 91 | + |
| 92 | +## Memakai Route Handler Sebagai Ganti |
| 93 | + |
| 94 | +Hook `staticHandler` mencakup satu prefix URL utuh, yang cocok untuk folder aset publik. Satu unduhan di balik auth atau logika bisnis lebih cocok dengan [route handler](/id/core-concepts/file-based-routing) biasa, tempat middleware berjalan lebih dulu dan kunci datang dari [route param](/id/core-concepts/route-patterns): |
| 95 | + |
| 96 | +```typescript twoslash |
| 97 | +import type { Context } from '@neabyte/deserve' |
| 98 | +declare const endpoint: string |
| 99 | +// ---cut--- |
| 100 | +// routes/files/[key].ts |
| 101 | +export async function GET(ctx: Context): Promise<Response> { |
| 102 | + const key = ctx.param('key') |
| 103 | + const object = await fetch(`${endpoint}/${key}`) |
| 104 | + if (!object.ok || !object.body) { |
| 105 | + return ctx.handleError(404, new Deno.errors.NotFound('Object not found')) |
| 106 | + } |
| 107 | + // Alirkan objek langsung apa adanya |
| 108 | + const contentType = object.headers.get('content-type') ?? 'application/octet-stream' |
| 109 | + return ctx.send.stream(object.body, undefined, contentType) |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +Jalur ini menjalankan rantai middleware penuh, jadi menjaganya dengan [basic auth](/id/middleware/basic-auth) atau pemeriksaan [session](/id/middleware/session) terjadi sebelum bucket disentuh sama sekali. |
| 114 | + |
| 115 | +## Menandatangani Request |
| 116 | + |
| 117 | +Bucket privat butuh request yang ditandatangani, bukan `fetch` polos. Dua jalur cocok: |
| 118 | + |
| 119 | +- **URL presigned** - SDK menandatangani URL berumur pendek, dan handler bisa mengalihkan dengan [`ctx.redirect`](/id/response/redirect) atau mengambilnya sisi server. |
| 120 | +- **SDK sisi server** - klien resmi menandatangani tiap request, misalnya [AWS SDK for JavaScript](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html) untuk S3 atau binding Cloudflare R2 untuk [Workers](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/). |
| 121 | + |
| 122 | +Jalur mana pun yang menandatangani request, body response tetap mengalir lewat `ctx.send.stream`, jadi bentuk penyajiannya tetap sama. |
| 123 | + |
| 124 | +## Menangani Kegagalan |
| 125 | + |
| 126 | +Object storage menambah panggilan jaringan yang bisa timeout atau ditolak, jadi tiap fetch meneruskan kegagalannya ke [penanganan error terpusat](/id/error-handling/object-details) alih-alih membocorkan error mentah. Objek yang hilang dipetakan ke **404**, sementara gangguan upstream dipetakan ke **502** agar penyebabnya tetap terbaca: |
| 127 | + |
| 128 | +```typescript twoslash |
| 129 | +import type { Context } from '@neabyte/deserve' |
| 130 | +declare const endpoint: string |
| 131 | +// ---cut--- |
| 132 | +export async function GET(ctx: Context): Promise<Response> { |
| 133 | + const key = ctx.param('key') |
| 134 | + try { |
| 135 | + const object = await fetch(`${endpoint}/${key}`) |
| 136 | + if (object.status === 404) { |
| 137 | + return await ctx.handleError(404, new Deno.errors.NotFound('Object not found')) |
| 138 | + } |
| 139 | + // Petakan kegagalan upstream ke 502 |
| 140 | + if (!object.ok || !object.body) { |
| 141 | + return await ctx.handleError(502, new Error('Object storage unavailable')) |
| 142 | + } |
| 143 | + return ctx.send.stream(object.body, undefined, 'application/octet-stream') |
| 144 | + } catch (error) { |
| 145 | + // Rutekan tiap gangguan jaringan ke error handling |
| 146 | + return await ctx.handleError(502, error as Error) |
| 147 | + } |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +Membentuk ini menjadi satu response klien tinggal di [Penanganan Error](/id/error-handling/object-details), dan menangkapnya untuk log tinggal di [Pelaporan Error](/id/middleware/observability/errors). |
0 commit comments