メインコンテンツまでスキップ

Git LFS Server を実装する

今回はGit LFS Severの最小Specのみ実装します。Git LFS Serverでは結局のところアップロード&ダウンロードができるURLを取得できれば最小構成となります。

実装するものはGit LFS APIBatch APIです。 これの他にFile Locking APIがありますが、今回は実装しません。File Locking APIも簡単に実装自体はできるため仕様を読みつつ作ってみてください。

WorkerからR2バケットのURLを生成する

基本的にR2はS3互換のAPIを提供しています。そのためaws-sdk/client-s3を利用することでR2の操作ができます。以下ではhonoを利用しているためR2のクライアントを作成するときにBindingsを要求する形になっています。

const r2 = (env: Bindings) =>
new S3Client({
region: "auto",
endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.LFS_BUCKET_KEY_ID,
secretAccessKey: env.LFS_BUCKET_SECRET,
},
});

...

const uploadUrl = await getSignedUrl(
r2client,
new PutObjectCommand({
Bucket: c.env.BUCKET_NAME,
Key: key,
}),
{
expiresIn: 60 * 60 * 24 * 1,
}
);

Honoを使ってルーティングを作成する

Git LFS Serverは前提としてContent-TypeとAcceptが application/vnd.git-lfs+json を求めています。これをチェックしなくても大きく問題が起きることはないですが、ここでは全ての要求にHeaderが適切に適用されていることを保証します。

const app = new Hono<{ Bindings: Bindings }>();
app.use("*", logger());
app.use("*", async (c, next) => {
const requestType = c.req.header("Accept");
if (requestType !== "application/vnd.git-lfs+json") {
return c.json({ message: "Not Acceptable" }, 406);
}
await next();
c.header("Content-Type", "application/vnd.git-lfs+json");
});

Honoを使ってSchemaを保証する

objects/batch のエンドポイントでは仕様として入力を定めています。これをzodのvalidatorを利用して篩にかけることができます。

transfersには色々な仕様を詰めることができます。basicを利用することでS3互換のget/putとして動くURLを返却すると動作するようにできます。

app.post(
"/:organization/:repository/objects/batch",
zValidator(
"json",
z.object({
operation: z.enum(["upload", "download"]),
transfers: z.array(z.union([z.string(), z.enum(["basic"])])),
ref: z
.object({
name: z.string(),
})
.optional(),
objects: z.array(
z.object({
oid: z.string(),
size: z.number(),
})
),
hash_algo: z.enum(["sha256"]).optional(),
}),
(value, c) => {
if (!value.success) {
return c.json({ message: "Bad Request - Validation Error" }, 400);
}
if (!value.data.transfers.includes("basic")) {
return c.json({ message: "Acceptable transfer - basic only" }, 501);
}
const { organization, repository } = c.req.param();
if (organization === "" || repository === "") {
return c.json(
{ message: "Bad Request - Path parameter is Empty" },
400
);
}
}
),
async (c) => {
...
}
)

本体実装

本体の処理はシンプルにupload/downloadの処理を記述しているだけです。

async (c) => {
const r2client = r2(c.env);
const data = c.req.valid("json");
const { organization, repository } = c.req.param();
switch (data.operation) {
case "upload":
const uploadObject = {
transfer: "basic",
objects: await Promise.all(
data.objects.map(async (obj) => {
// check object exists
const key = `${organization}/${repository}/${obj.oid}`;
const currentObj = await c.env.LFS_BUCKET.head(key);
if (currentObj !== null) {
return {
oid: obj.oid,
size: obj.size,
authenticated: true,
};
}

// new put object
const uploadUrl = await getSignedUrl(
r2client,
new PutObjectCommand({
Bucket: c.env.BUCKET_NAME,
Key: key,
}),
{
expiresIn: 60 * 60 * 24 * 1,
}
);
let expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1);
return {
oid: obj.oid,
size: obj.size,
authenticated: true,
actions: {
upload: {
href: uploadUrl,
expires_at: expiresAt,
},
},
};
})
),
hash_algo: "sha256",
};
return c.json(uploadObject, 200);
case "download":
const downloadObject = {
transfer: "basic",
objects: await Promise.all(
data.objects.map(async (obj) => {
// check object exists
const key = `${organization}/${repository}/${obj.oid}`;
const currentObj = await c.env.LFS_BUCKET.head(key);
if (currentObj === null) {
return {
oid: obj.oid,
size: obj.size,
error: {
code: 404,
message: "Object not found",
},
};
}

// get object
const downloadUrl = await getSignedUrl(
r2client,
new GetObjectCommand({
Bucket: c.env.BUCKET_NAME,
Key: key,
}),
{
expiresIn: 60 * 60 * 24 * 1,
}
);
let expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1);
return {
oid: obj.oid,
size: obj.size,
authenticated: true,
actions: {
download: {
href: downloadUrl,
expires_at: expiresAt,
},
},
};
})
),
hash_algo: "sha256",
};
return c.json(downloadObject, 200);
}
}