aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/remote.mjs
blob: 6ee6a59163ea45afde5221484f52200b1425c59b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import http from 'node:http';
import { MAX_PROJECT_BYTES, parseProject } from './web/project.mjs';

const maxRevision = 9223372036854775807n;
function revisionText(value, allowZero = false) {
  if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) ||
      value.length > 19 || BigInt(value) > maxRevision || (!allowZero && value === '0'))
    throw new Error('revision은 범위 안의 정수 문자열이어야 합니다.');
  return value;
}

export function remoteTarget(base, key) {
  const url = new URL(base);
  if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) ||
      url.username || url.password || url.pathname !== '/' || url.search || url.hash)
    throw new Error('현재 연결 호스트는 로컬 http://127.0.0.1:PORT 형식을 사용합니다.');
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(key)) throw new Error('작업물 이름은 영문·숫자·_·- 1~64자입니다.');
  url.hostname = '127.0.0.1';
  url.pathname = `/objects/${key}`;
  return url;
}

function request(url, method, body, headers = {}) {
  return new Promise((resolve, reject) => {
    let done = false;
    const finish = (error, value) => {
      if (done) return;
      done = true;
      clearTimeout(deadline);
      if (error) reject(error); else resolve(value);
    };
    const req = http.request(url, { method, agent: false, headers: { ...headers,
      'Content-Length': body?.byteLength ?? 0, Connection: 'close' } }, (res) => {
      const chunks = [];
      let size = 0;
      res.on('data', (chunk) => {
        size += chunk.length;
        if (size > MAX_PROJECT_BYTES) {
          const error = new Error('서버 작업물이 허용 크기를 넘었습니다.');
          finish(error);
          res.destroy(error);
          req.destroy(error);
        } else chunks.push(chunk);
      });
      res.on('error', (error) => finish(error));
      res.on('aborted', () => finish(new Error('응답이 완료되기 전에 연결이 끊겼습니다.')));
      res.on('end', () => {
        if (res.statusCode !== 200) {
          const error = new Error(`원격 작업 실패: ${res.statusCode} ${res.headers['x-pure-error'] ?? ''}`.trim());
          error.status = res.statusCode;
          error.etag = res.headers.etag;
          finish(error);
          return;
        }
        try {
          const match = /^"([1-9]\d*)"$/.exec(res.headers.etag ?? '');
          if (!match) throw new Error('서버의 revision 응답이 올바르지 않습니다.');
          const revision = revisionText(match[1]);
          finish(null, { revision, bytes: Buffer.concat(chunks, size) });
        } catch (error) { finish(error); }
      });
    });
    const deadline = setTimeout(() => {
      const error = new Error('원격 작업 시간 초과: 저장 완료 여부는 다시 확인해야 합니다.');
      finish(error);
      req.destroy(error);
    }, 12000);
    req.on('error', (error) => finish(error));
    req.end(body);
  });
}

export async function pushProject(target, expected, project) {
  revisionText(expected, true);
  const text = JSON.stringify(project);
  parseProject(text);
  const body = Buffer.from(text, 'utf8');
  const headers = expected === '0' ? { 'If-None-Match': '*' } : { 'If-Match': `"${expected}"` };
  const result = await request(target, 'PUT', body, headers);
  return result.revision;
}

export async function pullProject(target) {
  const result = await request(target, 'GET');
  const text = new TextDecoder('utf-8', { fatal: true }).decode(result.bytes);
  const project = parseProject(text);
  if (project.version !== 2) throw new Error('현재 배치를 포함한 version 2 작업물이 필요합니다.');
  return { project, revision: result.revision };
}