@lumir-company/dms-client
v0.3.0
Published
DMS 브라우저 클라이언트 SDK (업로드/다운로드/재개/미리보기/progress).
Downloads
1,487
Keywords
Readme
@lumir-company/dms-client
브라우저 환경에서 DMS 서버와 연동하기 위한 TypeScript SDK입니다.
- 파일 업로드/다운로드
- 브라우저
saveAs다운로드 - 파일 미리보기 (인라인 표시용 Blob)
- 폴더 조회/생성/이동/이름 변경/삭제
- 파일 이동/이름 변경/삭제
- DRM 복호화 결재 대리결재권자(위임) 등록/조회/삭제 (관리자)
- 휴가/휴가취소 이벤트 수신 → 대결권자 자동 등록·재산정 (서버 전용 진입점)
- NAS 직접 접근 (폴더 ID 없이 경로 기준 업로드/다운로드/폴더 생성, 대용량 멀티파트 자동)
- 업로드/다운로드/미리보기 진행률 콜백
- SDK 이벤트 관찰
- 전체 요청 취소
설치
npm install @lumir-company/dms-client빠른 시작
import { DmsClient } from '@lumir-company/dms-client';
const client = new DmsClient({
baseUrl: 'https://dms.example.com',
getToken: async () => '...jwt...',
});
const uploaded = await client.upload(file, {
folderId: 'folder-123',
onProgress: (progress) => {
console.log('upload', progress.percent);
},
});
const blob = await client.download(uploaded.id, {
onProgress: (progress) => {
console.log('download', progress.percent);
},
});클라이언트 생성
import { DmsClient } from '@lumir-company/dms-client';
const client = new DmsClient({
baseUrl: 'https://dms.example.com',
getToken: async ({ forceRefresh } = {}) => {
return forceRefresh ? 'new-token' : 'cached-token';
},
apiKey: 'dmsp_xxx',
tags: {
app: 'browser-admin',
screen: 'file-manager',
},
observer: {
onEvent(event) {
console.log(event.type, event.operation, event.traceId);
},
},
multipartThreshold: 100 * 1024 * 1024,
chunkSize: 5 * 1024 * 1024,
concurrency: 3,
retry: {
maxAttempts: 3,
initialDelayMs: 500,
maxDelayMs: 10000,
},
});주요 옵션
baseUrl: DMS 서버 절대 URL.http또는https만 허용됩니다.getToken: 요청마다 Bearer 토큰을 반환하는 비동기 함수입니다.apiKey: 서비스 프로젝트 API 키입니다. 설정하면 업로드/폴더 생성/파일 및 폴더 쓰기 요청에 자동 포함됩니다.tags: 모든 요청에 공통으로 붙는 추적 태그입니다.observer.onEvent: SDK 이벤트 수신기입니다.multipartThreshold: 이 크기 이상 파일은 multipart 업로드를 사용합니다.chunkSize: multipart 업로드 청크 크기입니다.concurrency: multipart 업로드 동시 전송 수입니다.retry: 네트워크/서버 오류 재시도 정책입니다.
업로드
기본 업로드
const result = await client.upload(file, {
folderId: 'folder-123',
});진행률 표시
const result = await client.upload(file, {
folderId: 'folder-123',
onProgress: ({ bytesUploaded, bytesTotal, percent, partsCompleted, partsTotal }) => {
console.log(bytesUploaded, bytesTotal, percent, partsCompleted, partsTotal);
},
});업로드 모드 강제
await client.upload(file, {
folderId: 'folder-123',
force: 'simple',
});
await client.upload(file, {
folderId: 'folder-123',
force: 'multipart',
});큐 대기 처리
서버가 multipart 업로드를 바로 시작하지 못하면 onQueued가 호출될 수 있습니다.
await client.upload(file, {
folderId: 'folder-123',
onQueued: async ({ ticket, proceed, cancel }) => {
console.log('queued', ticket);
const userAccepted = true;
if (userAccepted) {
await proceed();
return;
}
await cancel();
},
});lockPassword 사용
apiKey를 사용하지 않는 환경이면 쓰기 요청에서 lockPassword를 넘길 수 있습니다.
await client.upload(file, {
folderId: 'folder-123',
lockPassword: 'my-secret',
});업로드 반환값
upload()는 서버 응답을 그대로 포함한 UploadResult를 반환합니다.
const uploaded = await client.upload(file, { folderId: 'folder-123' });
console.log(uploaded.id);
console.log(uploaded.name);
console.log(uploaded.size);
console.log(uploaded.state);
console.log(uploaded.syncEventId);다운로드
Blob 다운로드
const blob = await client.download('file-123');진행률 표시
const blob = await client.download('file-123', {
onProgress: ({ bytesDownloaded, bytesTotal, percent }) => {
console.log(bytesDownloaded, bytesTotal, percent);
},
});브라우저 저장
await client.saveAs('file-123');
await client.saveAs('file-123', 'report.pdf');이어받기
const partialBlob = new Blob([partialBytes], { type: 'application/pdf' });
const completed = await client.download('file-123', {
resume: {
fromBlob: partialBlob,
etag: '"etag-value"',
},
});미리보기
서버 GET /v1/files/:fileId/preview 를 호출해 파일 바이트를 그대로 받습니다.
서버는 Content-Disposition: inline 으로 응답하므로, 브라우저가 렌더링 가능한
타입(이미지/PDF/비디오/오디오/텍스트 등)은 그대로 화면에 띄울 수 있고, 그렇지
않은 타입은 폴백 처리할 수 있습니다.
Bearer 인증을 쓰는 SDK 특성상 <img src="..."> 에 URL 을 직접 박을 수 없습니다.
대신 받아온 Blob 을 URL.createObjectURL 로 변환해서 사용합니다.
기본 사용
const blob = await client.preview('file-123');
const objectUrl = URL.createObjectURL(blob);
imgEl.src = objectUrl;
// 사용이 끝나면 메모리 해제
URL.revokeObjectURL(objectUrl);진행률 표시
const blob = await client.preview('file-123', {
onProgress: ({ bytesDownloaded, bytesTotal, percent }) => {
console.log(bytesDownloaded, bytesTotal, percent);
},
});타입별 렌더링 예시
const blob = await client.preview('file-123');
const url = URL.createObjectURL(blob);
if (blob.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = url;
document.body.appendChild(img);
} else if (blob.type === 'application/pdf') {
const frame = document.createElement('iframe');
frame.src = url;
document.body.appendChild(frame);
} else if (blob.type.startsWith('video/')) {
const video = document.createElement('video');
video.src = url;
video.controls = true;
document.body.appendChild(video);
}preview() 는 내부적으로 다운로드와 동일한 스트리밍 파이프라인을 사용하므로
signal, tags, resume 옵션도 동일하게 받습니다.
폴더 API
조회
const info = await client.folders.getInfo('folder-123');
const root = await client.folders.getRoot();
const qms = await client.folders.getQms();
const contents = await client.folders.listContents('folder-123', {
page: 1,
pageSize: 50,
sort: 'name,asc',
});
const tree = await client.folders.getTree('folder-123');
const treeByPath = await client.folders.getTreeByPath('/projects/2026');
const searched = await client.folders.search('folder-123', {
keyword: 'report',
page: 1,
pageSize: 20,
});생성
const created = await client.folders.create({
name: '2026 계획',
parentId: 'parent-folder-id',
});
const createdWithPassword = await client.folders.create({
name: '보안 폴더',
parentId: 'parent-folder-id',
lockPassword: 'my-secret',
});이동, 이름 변경, 삭제
await client.folders.move('folder-123', {
targetParentId: 'target-parent-id',
});
await client.folders.rename('folder-123', {
name: '변경된 폴더명',
});
await client.folders.delete('folder-123');파일 API
await client.files.move('file-123', {
targetFolderId: 'target-folder-id',
});
await client.files.rename('file-123', {
name: 'renamed-report.pdf',
});
await client.files.delete('file-123');DRM 대리결재권자 API
DRM 복호화 요청 결재의 대리결재권자(위임) 를 관리합니다. 원결재자(위임자)의 부재 기간 동안 결재 권한을 대리자(수임자)에게 위임하면, 기간 중 신규 상신되는 복호화 요청은 상신 시점에 대리자로 라우팅되고 기간이 끝나면 신규 건은 자동으로 원결재자로 원복됩니다. 이미 대리자에게 배정된 미결 건은 대리자가 계속 처리합니다.
서버 엔드포인트(
/v1/drm-approval-delegations)는 관리자용입니다. 다만 현재 서버는 JWT 인증만 검증하며 역할(Role) 가드는 미적용 상태이므로, 일반 사용자 토큰으로도 호출이 성공할 수 있습니다. 화면 노출은 호출 측에서 관리자 권한으로 제한하세요.apiKey는 사용하지 않으며 요청 body 에도 주입되지 않습니다.
등록
const delegation = await client.approvalDelegations.create({
delegatorEmployeeNumber: '20028', // 원결재자(위임자) 사번
delegateEmployeeNumber: '23027', // 대리자(수임자) 사번
startAt: '2026-07-01T00:00:00+09:00',
endAt: '2026-07-07T23:59:59+09:00',
reason: '하계 휴가', // 선택 (최대 500자)
});
console.log(delegation.id, delegation.source); // 'MANUAL'검증 규칙 — 위반 시 서버가 400 을 반환하며 SDK 는 DmsValidationError 로 throw 합니다.
delegatorEmployeeNumber !== delegateEmployeeNumber(원결재자와 대리자가 같을 수 없음)endAt > startAt
등록은 서버에서 중복 방어(멱등 처리)를 하지 않습니다. 네트워크 오류로 SDK 가 자동 재시도하면 동일 구간이 두 건 등록될 수 있습니다(겹치는 위임은 가장 최근 생성건이 적용되므로 동작은 동일). 중복이 신경 쓰이는 화면이라면 등록 후
list()로 확인하거나retry.maxAttempts: 1로 클라이언트를 생성하세요.
목록 조회
const all = await client.approvalDelegations.list();
const active = await client.approvalDelegations.list({
delegatorEmployeeNumber: '20028',
activeOnly: true, // 조회 시점에 기간 내인 위임만
page: 1,
pageSize: 20, // 최대 100
});
console.log(active.items, active.totalItems, active.hasNext);응답은 items + 페이지네이션 필드(page, pageSize, totalItems, totalPages,
hasNext, hasPrev)로 구성되며 startAt 내림차순으로 정렬됩니다.
삭제(취소)
await client.approvalDelegations.delete(delegation.id);서버가 204(본문 없음)를 반환하므로 반환값은 없습니다. 미등록 id 는 DmsNotFoundError 입니다.
삭제 후 해당 원결재자의 신규 건은 원결재자로 처리됩니다.
등록 출처(source)
대결권자를 등록하는 경로는 2개입니다.
| 값 | 등록 경로 | 인증 |
| --- | --- | --- |
| MANUAL | 관리자가 이 API 로 직접 등록 | 사용자 JWT |
| LEAVE_EVENT | 휴가 이벤트 수신으로 서버가 자동 등록 | 사전 공유 토큰 (아래 참조) |
휴가 이벤트로 자동 등록된 건(LEAVE_EVENT)도 목록에 함께 조회되며 이 API 로 삭제할 수 있습니다.
source 컬럼 도입 이전 버전의 서버는 응답에 source 를 포함하지 않으므로 타입상 optional 입니다.
휴가 이벤트 API (서버 전용)
그룹웨어·인사 시스템이 휴가 일정을 DMS 로 push 하면 대결권자 위임이 자동 등록·재산정됩니다. 이 API 는 사전 공유 토큰 헤더로 인증하므로 브라우저에서 호출할 수 없습니다 — 토큰이 번들에 포함되면 그대로 유출됩니다. 그래서 별도 서브패스로만 노출됩니다.
import { DmsServerClient } from '@lumir-company/dms-client/server';
const server = new DmsServerClient({
baseUrl: process.env.DMS_BASE_URL!, // 운영 https://ldms.lumir.space
authToken: process.env.DMS_LEAVE_EVENT_TOKEN!, // 서버 LEAVE_EVENT_AUTH_TOKEN 과 일치
// authHeaderName: 'x-leave-event-auth', // 기본값. 서버 설정을 바꿨을 때만 지정
});토큰은 x-leave-event-auth 헤더에 Bearer 접두사 없이 실립니다.
휴가 등록
const res = await server.leaveEvents.submit({
delegatorEmployeeNumber: '20028', // 휴가자(원결재자)
delegateEmployeeNumber: '23027', // 대결권자(대리자)
dates: ['2026-07-01', '2026-07-02', '2026-07-04'], // 하루 단위, KST
reason: '하계 휴가', // 선택
});
// 이어지는 날짜는 하나의 구간으로 묶임
// res.ranges → [{ 7-01 ~ 7-02 }, { 7-04 ~ 7-04 }], res.createdCount → 2- 날짜는 하루 단위 배열(
YYYY-MM-DD), 순서·중복 무관 - 동일 구간 재전송은 멱등 — 중복 생성 없이
createdCount: 0 - 400: 빈 배열 / 형식 위반 / 휴가자=대결권자 / 미존재 사번, 401: 토큰 불일치(errorCode
10409)
휴가 취소
const res = await server.leaveEvents.cancel({
delegatorEmployeeNumber: '20028',
dates: ['2026-07-02'], // 취소일만
});
// [7/1~7/3] 에서 7/2 취소 → [7/1], [7/3] 로 분할 재등록
// res.deletedCount → 1, res.createdCount → 2, res.remaining → 남은 구간취소는 휴가 이벤트 출처(LEAVE_EVENT) 위임만 재산정하며, 관리자가 수동 등록한
MANUAL 위임은 건드리지 않습니다.
연동 문서
이벤트를 전송하는 측 담당자에게 전달할 API 계약서는 DMS 서버 저장소에 있습니다.
| 위치 | 용도 |
| --- | --- |
| docs/leave-events-api/README.md | 연동 안내(엔드포인트·인증·규칙·curl 예시) |
| docs/leave-events-api/openapi.yaml | OpenAPI 3.0 정본 (Swagger Editor/Postman import) |
| docs/leave-events-api/redoc.html | 더블클릭으로 여는 오프라인 ReDoc 문서 |
| https://ldms.lumir.space/api-docs-redoc | 운영 서버가 서빙하는 전체 API 문서 (태그 724.휴가/대결권자 이벤트 수신 API) |
| https://ldms-dev.lumir.space/api-docs-redoc | 개발 서버 동일 |
NAS 직접 접근
client.nas 는 Lumir Drive 싱크 엔진과 같은 경로 기반 계약(/nas/*)을 씁니다.
폴더 UUID 를 먼저 조회해야 하는 client.upload() 와 달리, 폴더 경로 + 파일명만으로 NAS 에 바로
씁니다. 인증은 동일한 사용자 Bearer 토큰(getToken)입니다.
업로드 대상 경로 찾기
folderPath 는 서버가 만들어 주는 값이라 임의로 조합할 수 없습니다. 진입점 목록에서 가져옵니다.
const roots = await client.nas.myRoots();
// [{ folderPath: '/공유문서', canWrite: true, kind: 'shared', name: '공유문서' }, ...]
const target = roots.find((r) => r.canWrite);폴더 만들기
업로드는 폴더를 자동으로 만들지 않습니다. 없는 경로로 올리면 404 로 거부되므로 먼저 만듭니다. 멱등이라 이미 있으면 그대로 성공합니다.
await client.nas.createFolder({ path: 'ProjectNas', name: '2026' });
await client.nas.createFolder({ path: 'ProjectNas/2026', name: '설계' });여러 단계를 한 번에 만들 수는 없어 위에서부터 한 단계씩 호출해야 합니다. 루트(진입점) 자체는 만들 수 없습니다 — 새 루트가 필요하면 공유폴더 관리에서 등록해야 합니다.
업로드
const result = await client.nas.upload({
folderPath: `${target.folderPath}/2026`,
filename: 'report.pdf',
data: file, // File | Blob | ArrayBuffer | Uint8Array
overwrite: false, // 기본값. 같은 이름이 있으면 412
});
// { etag: 'W/"..."', mode: 'simple' | 'multipart', size: 1234, parts?: 3 }크기에 따라 전송 방식을 자동으로 고릅니다. 8MB(NAS_MULTIPART_THRESHOLD) 미만은 단일 요청,
그 이상은 멀티파트로 전환해 서버가 알려준 partSize 로 나눠 순차 전송한 뒤 조립합니다.
중간에 실패하면 서버 세션을 정리하는 중단 요청을 보냅니다.
기준이 8MB 로 낮은 이유: 서버의 단일 업로드 핸들러가 본문을 통째로 버퍼링해 요청이 길어집니다. 실측에서 25MB 단일 업로드가 183초 걸렸고, 그 사이 응답이 유실되면 재시도가 이미 만들어진 파일과 충돌해 파일은 저장됐는데 412 로 실패하는 상태가 됩니다. 큰 파일은 멀티파트로 보내세요.
// 이 호출에 한해 전환 기준을 바꾼다
await client.nas.upload(input, { multipartThreshold: 32 * 1024 * 1024 });덮어쓰기와 낙관적 잠금
try {
await client.nas.upload({ folderPath, filename, data, overwrite: false });
} catch (e) {
if (e instanceof DmsConflictError) {
// 412 — 같은 이름이 이미 있음. 사용자에게 덮어쓰기 여부를 묻고 overwrite: true 로 재시도.
}
}
// 내가 읽은 그 버전일 때만 덮어쓰기 (단일 업로드 전용)
await client.nas.upload({ folderPath, filename, data, overwrite: true, ifMatch: prevEtag });다운로드
경로로 바로 내려받습니다. 포털 파일 API 의 client.download(fileId) 와 달리 UUID 가 필요 없습니다.
const { blob, contentType, etag } = await client.nas.download('ProjectNas/2026/report.pdf');
// 부분 다운로드 / 이어받기 — 앞서 받은 etag 를 ifRange 로 넘기면
// 그 사이 파일이 바뀌었을 때 서버가 전체를 다시 보냅니다.
const rest = await client.nas.download(path, {
range: { start: received.size },
ifRange: etag,
});
console.log(rest.partial); // 206 이면 true진행률
업로드 진행률은 observer 로 관찰합니다(멀티파트는 파트마다 발행).
const client = new DmsClient({
baseUrl,
getToken,
observer: {
onEvent: (e) => {
if (e.operation === 'nas:upload' && e.type === 'upload:progress') {
console.log(`${e.percent}% (${e.partsCompleted}/${e.partsTotal})`);
}
},
},
});주의사항
- 동시 업로드: 멀티파트 세션은 서버에서
(folderPath, filename)으로 식별됩니다(세션 ID 없음). 같은 폴더에 같은 파일명으로 동시에 올리면 서로의 파트를 덮어쓰고, 중단 요청도 상대 세션을 지웁니다. 파일명을 다르게 하거나 호출 측에서 직렬화하세요. - 슬롯 대기: 서버가 동시 업로드 수를 제한합니다. 슬롯이 없으면
503이 오고, SDK 가 기본 5회 × 3초 간격으로 기다렸다 재시도합니다(slotWaitAttempts,slotWaitDelayMs로 조정). - 잠긴 폴더:
canWrite: false인 루트에는 쓸 수 없습니다.
공통 옵션
업로드/다운로드/폴더/파일 API 일부는 아래 옵션을 공통으로 받습니다.
signal:AbortController로 요청 취소tags: 요청별 추적 태그 추가lockPassword: 쓰기 요청 시 문서 잠금 비밀번호 전달
예시:
const controller = new AbortController();
await client.files.rename(
'file-123',
{ name: 'report-final.pdf' },
{
signal: controller.signal,
tags: { feature: 'bulk-rename' },
lockPassword: 'my-secret',
},
);취소
단일 요청 취소
const controller = new AbortController();
const promise = client.download('file-123', {
signal: controller.signal,
});
controller.abort();
await promise;전체 요청 취소
client.cancelAll();이벤트 관찰
observer.onEvent로 SDK 내부 이벤트를 구독할 수 있습니다.
const client = new DmsClient({
baseUrl: 'https://dms.example.com',
getToken: async () => 'token',
observer: {
onEvent(event) {
switch (event.type) {
case 'session:start':
case 'session:complete':
case 'session:aborted':
case 'upload:progress':
case 'download:progress':
case 'request:start':
case 'request:complete':
case 'request:error':
case 'request:retry':
case 'queue:wait':
console.log(event);
break;
}
},
},
});대표 event.type:
session:startsession:completesession:abortedrequest:startrequest:completerequest:errorrequest:retryupload:progressdownload:progress(다운로드와 미리보기 모두에서 발생,event.operation으로 구분)queue:waitresume:persistedresume:matchedresume:mismatch
resumePending
resumePending()은 현재 IndexedDB에 저장된 보류 업로드 세션 목록만 반환합니다.
실제 업로드 재개 실행까지 자동으로 처리하지는 않습니다.
const pendingSessions = await client.resumePending();
console.log(pendingSessions);에러 처리
SDK는 다음 에러 클래스를 export 합니다.
DmsSdkErrorDmsNetworkErrorDmsHttpErrorDmsAuthErrorDmsNotFoundErrorDmsValidationErrorDmsAbortErrorDmsQueueErrorDmsResumeErrorDmsConfigError
import { DmsAuthError, DmsHttpError } from '@lumir-company/dms-client';
try {
await client.download('file-123');
} catch (error) {
if (error instanceof DmsAuthError) {
console.error('auth failed', error.status, error.traceId);
} else if (error instanceof DmsHttpError) {
console.error('http error', error.status, error.body);
} else {
console.error(error);
}
}TypeScript export
패키지는 아래 주요 타입을 함께 export 합니다.
import type {
DmsClientConfig,
UploadOptions,
DownloadOptions,
UploadProgress,
DownloadProgress,
UploadResult,
WriteOpts,
FolderReadOpts,
FolderInfo,
RootFolderInfo,
FolderTreeNode,
FolderFlatNode,
FolderContents,
FolderItem,
GetFolderContentsQuery,
SearchFolderContentsQuery,
CreateFolderRequest,
CreateFolderResponse,
MoveFolderRequest,
RenameFolderRequest,
MoveFileRequest,
RenameFileRequest,
ApprovalDelegation,
ApprovalDelegationList,
ApprovalDelegationOpts,
CreateApprovalDelegationRequest,
ListApprovalDelegationsQuery,
DelegationSource,
SdkEvent,
SdkEventBase,
SdkOperation,
} from '@lumir-company/dms-client';휴가 이벤트 타입은 서버 전용 진입점에서 export 됩니다.
import type {
DmsServerClientConfig,
LeaveEventOpts,
LeaveEventRequest,
LeaveEventResult,
LeaveCancelEventRequest,
LeaveCancelEventResult,
DateRun,
DelegateDateRun,
} from '@lumir-company/dms-client/server';개발
npm install
npm run build
npm test
npm run test:int
npm run demo참고
- 브라우저 데모:
examples/browser-demo - 상세 스펙 문서:
docs/specs/
📦 배포 (자동)
이 패키지(@lumir-company/dms-client)는 release-please + self-hosted GitHub Actions 러너로 자동 배포됩니다. 버전을 수동으로 올리거나 npm publish를 직접 실행할 필요가 없습니다.
버전 규칙 (Semantic Versioning)
커밋 메시지(Conventional Commits)가 다음 버전을 자동 결정합니다:
| 커밋 prefix | 버전 증가 | 예시 |
| --- | --- | --- |
| fix: … | patch | 1.2.3 → 1.2.4 |
| feat: … | minor | 1.2.3 → 1.3.0 |
| feat!: … 또는 본문 BREAKING CHANGE: | major | 1.2.3 → 2.0.0 |
| chore: docs: refactor: test: 등 | 릴리스 없음 | — |
배포 흐름
main브랜치에 conventional commit을 push 한다.- release-please가
chore(main): release X.Y.Z형태의 Release PR을 자동 생성한다 (버전 bump +CHANGELOG.md갱신). - 그 PR을 검토 후 머지한다. ← 사람이 하는 유일한 단계
- 머지되면 자동으로 Git 태그 + GitHub Release 생성 + npm publish 가 실행된다 (
.github/workflows/release.yml, self-hosted 러너lumir-ci).
npm 인증은 repo secret
NPM_TOKEN(Automation/Publish 토큰), release-please는RELEASE_PLEASE_TOKEN(PAT)을 사용한다.
