storage.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { AwsClient } from "aws4fetch"
  2. import { lazy } from "@opencode-ai/shared/util/lazy"
  3. export namespace Storage {
  4. export interface Adapter {
  5. read(path: string): Promise<string | undefined>
  6. write(path: string, value: string): Promise<void>
  7. remove(path: string): Promise<void>
  8. list(options?: { prefix?: string; limit?: number; after?: string; before?: string }): Promise<string[]>
  9. }
  10. function createAdapter(client: AwsClient, endpoint: string, bucket: string): Adapter {
  11. const base = `${endpoint}/${bucket}`
  12. return {
  13. async read(path: string): Promise<string | undefined> {
  14. const response = await client.fetch(`${base}/${path}`)
  15. if (response.status === 404) return undefined
  16. if (!response.ok) throw new Error(`Failed to read ${path}: ${response.status}`)
  17. return response.text()
  18. },
  19. async write(path: string, value: string): Promise<void> {
  20. const response = await client.fetch(`${base}/${path}`, {
  21. method: "PUT",
  22. body: value,
  23. headers: {
  24. "Content-Type": "application/json",
  25. },
  26. })
  27. if (!response.ok) throw new Error(`Failed to write ${path}: ${response.status}`)
  28. },
  29. async remove(path: string): Promise<void> {
  30. const response = await client.fetch(`${base}/${path}`, {
  31. method: "DELETE",
  32. })
  33. if (!response.ok) throw new Error(`Failed to remove ${path}: ${response.status}`)
  34. },
  35. async list(options?: { prefix?: string; limit?: number; after?: string; before?: string }): Promise<string[]> {
  36. const prefix = options?.prefix || ""
  37. const params = new URLSearchParams({ "list-type": "2", prefix })
  38. if (options?.limit) params.set("max-keys", options.limit.toString())
  39. if (options?.after) {
  40. const afterPath = prefix + options.after + ".json"
  41. params.set("start-after", afterPath)
  42. }
  43. const response = await client.fetch(`${base}?${params}`)
  44. if (!response.ok) throw new Error(`Failed to list ${prefix}: ${response.status}`)
  45. const xml = await response.text()
  46. const keys: string[] = []
  47. const regex = /<Key>([^<]+)<\/Key>/g
  48. let match
  49. while ((match = regex.exec(xml)) !== null) {
  50. keys.push(match[1])
  51. }
  52. if (options?.before) {
  53. const beforePath = prefix + options.before + ".json"
  54. return keys.filter((key) => key < beforePath)
  55. }
  56. return keys
  57. },
  58. }
  59. }
  60. function s3(): Adapter {
  61. const bucket = process.env.OPENCODE_STORAGE_BUCKET!
  62. const region = process.env.OPENCODE_STORAGE_REGION || "us-east-1"
  63. const client = new AwsClient({
  64. region,
  65. accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!,
  66. secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!,
  67. })
  68. return createAdapter(client, `https://s3.${region}.amazonaws.com`, bucket)
  69. }
  70. function r2() {
  71. const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID!
  72. const client = new AwsClient({
  73. accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!,
  74. secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!,
  75. })
  76. return createAdapter(client, `https://${accountId}.r2.cloudflarestorage.com`, process.env.OPENCODE_STORAGE_BUCKET!)
  77. }
  78. const adapter = lazy(() => {
  79. const type = process.env.OPENCODE_STORAGE_ADAPTER
  80. if (type === "r2") return r2()
  81. if (type === "s3") return s3()
  82. throw new Error("No storage adapter configured")
  83. })
  84. function resolve(key: string[]) {
  85. return key.join("/") + ".json"
  86. }
  87. export async function read<T>(key: string[]) {
  88. const result = await adapter().read(resolve(key))
  89. if (!result) return undefined
  90. return JSON.parse(result) as T
  91. }
  92. export function write<T>(key: string[], value: T) {
  93. return adapter().write(resolve(key), JSON.stringify(value))
  94. }
  95. export function remove(key: string[]) {
  96. return adapter().remove(resolve(key))
  97. }
  98. export async function list(options?: { prefix?: string[]; limit?: number; after?: string; before?: string }) {
  99. const p = options?.prefix ? options.prefix.join("/") + (options.prefix.length ? "/" : "") : ""
  100. const result = await adapter().list({
  101. prefix: p,
  102. limit: options?.limit,
  103. after: options?.after,
  104. before: options?.before,
  105. })
  106. return result.map((x) => x.replace(/\.json$/, "").split("/"))
  107. }
  108. export async function update<T>(key: string[], fn: (draft: T) => void) {
  109. const val = await read<T>(key)
  110. if (!val) throw new Error("Not found")
  111. fn(val)
  112. await write(key, val)
  113. return val
  114. }
  115. }