@dooboostore/simple-boot-http-server 1.0.30 → 1.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -1
- package/dist/cjs/SimpleBootHttpServer.js +6 -3
- package/dist/cjs/SimpleBootHttpServer.js.map +2 -2
- package/dist/cjs/codes/Mimes.js +2 -0
- package/dist/cjs/codes/Mimes.js.map +2 -2
- package/dist/cjs/generators/ContentTypeGenerator.js +320 -0
- package/dist/cjs/generators/ContentTypeGenerator.js.map +7 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/models/RequestResponse.js +18 -4
- package/dist/cjs/models/RequestResponse.js.map +2 -2
- package/dist/cjs/option/HttpServerOption.js +2 -1
- package/dist/cjs/option/HttpServerOption.js.map +2 -2
- package/dist/esm/SimpleBootHttpServer.js +6 -3
- package/dist/esm/SimpleBootHttpServer.js.map +2 -2
- package/dist/esm/codes/Mimes.js +2 -0
- package/dist/esm/codes/Mimes.js.map +2 -2
- package/dist/esm/generators/ContentTypeGenerator.js +287 -0
- package/dist/esm/generators/ContentTypeGenerator.js.map +7 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +2 -2
- package/dist/esm/models/RequestResponse.js +18 -4
- package/dist/esm/models/RequestResponse.js.map +2 -2
- package/dist/esm/option/HttpServerOption.js +2 -1
- package/dist/esm/option/HttpServerOption.js.map +2 -2
- package/dist/esm-bundle/dooboostore-simple-boot-http-server.esm.js +678 -5240
- package/dist/esm-bundle/dooboostore-simple-boot-http-server.esm.js.map +4 -4
- package/dist/types/SimpleBootHttpServer.d.ts.map +1 -1
- package/dist/types/codes/Mimes.d.ts +2 -0
- package/dist/types/codes/Mimes.d.ts.map +1 -1
- package/dist/types/generators/ContentTypeGenerator.d.ts +172 -0
- package/dist/types/generators/ContentTypeGenerator.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/models/RequestResponse.d.ts +4 -1
- package/dist/types/models/RequestResponse.d.ts.map +1 -1
- package/dist/types/option/HttpServerOption.d.ts.map +1 -1
- package/dist/umd-bundle/dooboostore-simple-boot-http-server.umd.js +678 -5240
- package/dist/umd-bundle/dooboostore-simple-boot-http-server.umd.js.map +4 -4
- package/package.json +4 -4
- package/src/SimpleBootHttpServer.ts +7 -3
- package/src/codes/Mimes.ts +2 -0
- package/src/generators/ContentTypeGenerator.ts +431 -0
- package/src/index.ts +1 -0
- package/src/models/RequestResponse.ts +21 -4
- package/src/option/HttpServerOption.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooboostore/simple-boot-http-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -99,10 +99,10 @@
|
|
|
99
99
|
"node-gzip": "^1.1.2",
|
|
100
100
|
"ws": "^8.18.3",
|
|
101
101
|
"@dooboostore/core": "1.0.33",
|
|
102
|
-
"@dooboostore/
|
|
103
|
-
"@dooboostore/simple-boot": "1.0.59",
|
|
102
|
+
"@dooboostore/dom-render": "1.0.115",
|
|
104
103
|
"@dooboostore/core-node": "1.0.14",
|
|
105
|
-
"@dooboostore/
|
|
104
|
+
"@dooboostore/core-web": "1.0.14",
|
|
105
|
+
"@dooboostore/simple-boot": "1.0.60"
|
|
106
106
|
},
|
|
107
107
|
"scripts": {
|
|
108
108
|
"clean": "rm -rf dist",
|
|
@@ -28,6 +28,7 @@ import {URLSearchParams} from 'url';
|
|
|
28
28
|
import {HttpMethod} from './codes/HttpMethod';
|
|
29
29
|
import {SessionManager} from './session/SessionManager';
|
|
30
30
|
import {InjectSituationType} from './inject/InjectSituationType';
|
|
31
|
+
import {ContentTypeGenerator} from './generators/ContentTypeGenerator';
|
|
31
32
|
import {ReqSearchParamsObj} from "./models/datas/path/ReqSearchParamsObj";
|
|
32
33
|
import {SimConfig} from '@dooboostore/simple-boot';
|
|
33
34
|
import {ReqPathData} from './models/datas/path/ReqPathData';
|
|
@@ -342,13 +343,16 @@ export class SimpleBootHttpServer extends SimpleApplication {
|
|
|
342
343
|
// 핸들러가 ServerResponse를 직접 주입받아 스스로 write/end를 제어한다 (예: SSE 스트리밍)
|
|
343
344
|
} else if (ValidUtils.isGenerator(data) || ValidUtils.isAsyncGenerator(data)) {
|
|
344
345
|
// 제너레이터 리턴: 헤더 먼저 보내고, yield 된 값을 하나씩 바로 body 로 흘려보낸다 (예: SSE)
|
|
346
|
+
// ContentTypeGenerator.Stream 이면 Content-Type(boundary 포함)·압축 여부를 스트림이 정한다
|
|
347
|
+
const stream = ContentTypeGenerator.isStream(data) ? data : undefined;
|
|
345
348
|
const headers = resConfig?.header ?? {};
|
|
346
|
-
|
|
347
|
-
|
|
349
|
+
const contentType = stream?.contentType ?? resConfig?.contentType;
|
|
350
|
+
if (contentType) {
|
|
351
|
+
headers[HttpHeaders.ContentType] = contentType;
|
|
348
352
|
}
|
|
349
353
|
rr.resSetHeaders(headers);
|
|
350
354
|
rr.resSetStatusCode(resConfig?.status ?? HttpStatus.Ok);
|
|
351
|
-
await rr.resStream(data);
|
|
355
|
+
await rr.resStream(data, { compress: stream?.compress });
|
|
352
356
|
} else {
|
|
353
357
|
if (it.config?.resolver) {
|
|
354
358
|
const execute = typeof it.config.resolver === 'function' ? this.simstanceManager.getOrNewSim({target: it.config.resolver}) : it.config.resolver;
|
package/src/codes/Mimes.ts
CHANGED
|
@@ -26,6 +26,8 @@ export enum Mimes {
|
|
|
26
26
|
MultipartFormData = 'multipart/form-data',
|
|
27
27
|
TextEventStream = 'text/event-stream', // SSE 실시간 텍스트 스트리밍
|
|
28
28
|
ApplicationXndjson = 'application/x-ndjson',
|
|
29
|
+
ApplicationJsonSeq = 'application/json-seq', // RFC 7464 JSON Text Sequences
|
|
30
|
+
TextCsv = 'text/csv',
|
|
29
31
|
ApplicationJsonPostSimpleBootSsrIntentScheme = 'application/json-post+simple-boot-ssr-intent-scheme',
|
|
30
32
|
Intent = 'intent'
|
|
31
33
|
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import mime from 'mime-types';
|
|
3
|
+
import { ValidUtils } from '@dooboostore/core';
|
|
4
|
+
import { Mimes } from '../codes/Mimes';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Content-Type 별 스트리밍 응답 묶음.
|
|
8
|
+
* 핸들러는 내용(payload)만 넘기고, 형식(SSE `data:`·multipart boundary·CRLF·Content-Type·Content-Length·NDJSON 줄바꿈)은 여기서 붙인다.
|
|
9
|
+
*
|
|
10
|
+
* 돌려주는 Stream 은 그대로 async generator 라서 핸들러에서 return 하면 프레임워크가 yield 마다 바로 body 로 쏜다.
|
|
11
|
+
* Stream 이 contentType(boundary 포함)·compress 를 들고 있어 데코레이터에 contentType 을 따로 적지 않아도 된다.
|
|
12
|
+
*
|
|
13
|
+
* pull — 내 반복문이 만들어 yield 할 때:
|
|
14
|
+
* return ContentTypeGenerator.multipartReplace(this.frames(file, signal));
|
|
15
|
+
* push — 콜백·이벤트로 올 때마다 넣을 때:
|
|
16
|
+
* const out = ContentTypeGenerator.multipartReplace.channel({ signal, keep: 'latest' });
|
|
17
|
+
* watcher.on('change', buf => out.push(buf));
|
|
18
|
+
* out.onClose(() => watcher.close());
|
|
19
|
+
* return out;
|
|
20
|
+
*/
|
|
21
|
+
export namespace ContentTypeGenerator {
|
|
22
|
+
export type Source<T> = Iterable<T> | AsyncIterable<T>;
|
|
23
|
+
|
|
24
|
+
/** 프레임워크가 받는 스트림: async generator + 응답 헤더 정보 */
|
|
25
|
+
export type Stream<T = string | Buffer> = AsyncGenerator<T, void, undefined> & {
|
|
26
|
+
readonly contentType: string;
|
|
27
|
+
/** false 면 Accept-Encoding: gzip 이어도 압축 안 함 (JPEG 처럼 이미 압축된 내용) */
|
|
28
|
+
readonly compress: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** push 용: Stream + 넣기·닫기 */
|
|
32
|
+
export type Channel<In, Out = string | Buffer> = Stream<Out> & {
|
|
33
|
+
/** 넣기. 닫혔으면 false */
|
|
34
|
+
push(value: In): boolean;
|
|
35
|
+
/** 더 넣을 게 없음 — 쌓인 건 마저 보내고 끝냄 */
|
|
36
|
+
close(): void;
|
|
37
|
+
readonly closed: boolean;
|
|
38
|
+
/** 끝날 때(클라이언트 끊김·close·signal abort) 한 번 — 감시자·리스너 정리용 */
|
|
39
|
+
onClose(fn: () => void): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ChannelOption = {
|
|
43
|
+
/** abort 되면 채널을 닫음 */
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
/** 'queue': 하나도 안 버리고 순서대로 (이벤트) · 'latest': 밀리면 최신 하나만 (영상 프레임) */
|
|
46
|
+
keep?: 'queue' | 'latest';
|
|
47
|
+
/** 'queue' 최대 개수 — 넘치면 가장 오래된 것부터 버림 (기본 1000) */
|
|
48
|
+
max?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const isStream = (value: any): value is Stream =>
|
|
52
|
+
ValidUtils.isAsyncGenerator(value) && typeof (value as any).contentType === 'string';
|
|
53
|
+
|
|
54
|
+
// ───────────────────────── 형식 규칙 (순수 함수) ─────────────────────────
|
|
55
|
+
|
|
56
|
+
export type SseEvent =
|
|
57
|
+
| string
|
|
58
|
+
| { data?: unknown; event?: string; id?: string; retry?: number; comment?: string };
|
|
59
|
+
|
|
60
|
+
const oneLine = (v: string) => v.replace(/[\r\n]/g, ' ');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SSE 이벤트 하나 → `field: value\n` 줄들 + 빈 줄(\n).
|
|
64
|
+
* data 가 여러 줄이면 줄마다 `data: ` (그냥 넣으면 줄바꿈에서 이벤트가 끊김). 문자열이 아니면 JSON.
|
|
65
|
+
*/
|
|
66
|
+
export const sseChunk = (e: SseEvent): string => {
|
|
67
|
+
const ev = typeof e === 'string' ? { data: e } : e;
|
|
68
|
+
let out = '';
|
|
69
|
+
if (ev.comment !== undefined) out += ev.comment.split(/\r\n|\r|\n/).map(l => `: ${l}\n`).join('');
|
|
70
|
+
if (ev.event !== undefined) out += `event: ${oneLine(ev.event)}\n`;
|
|
71
|
+
if (ev.id !== undefined) out += `id: ${oneLine(ev.id)}\n`;
|
|
72
|
+
if (ev.retry !== undefined) out += `retry: ${Math.max(0, Math.floor(ev.retry))}\n`;
|
|
73
|
+
if (ev.data !== undefined) {
|
|
74
|
+
const text = typeof ev.data === 'string' ? ev.data : JSON.stringify(ev.data);
|
|
75
|
+
out += text.split(/\r\n|\r|\n/).map(l => `data: ${l}\n`).join('');
|
|
76
|
+
}
|
|
77
|
+
return out + '\n';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** 연결 유지용 SSE 주석 (브라우저 EventSource 는 무시) */
|
|
81
|
+
export const ssePing = (): string => ': ping\n\n';
|
|
82
|
+
|
|
83
|
+
export type Part =
|
|
84
|
+
| Buffer
|
|
85
|
+
| Uint8Array
|
|
86
|
+
| string
|
|
87
|
+
| { body: Buffer | Uint8Array | string; type?: string; name?: string; headers?: Record<string, string> };
|
|
88
|
+
|
|
89
|
+
/** 바이트 앞부분으로 흔한 이미지 판별 (mime-types 는 이름으로만 찾음) — 이름 없이 Buffer만 온 프레임용 */
|
|
90
|
+
const sniff = (b: Buffer): string | undefined => {
|
|
91
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return Mimes.ImageJpeg;
|
|
92
|
+
if (b.length >= 8 && b.readUInt32BE(0) === 0x89504e47 && b.readUInt32BE(4) === 0x0d0a1a0a) return Mimes.ImagePng;
|
|
93
|
+
if (b.length >= 6 && b.toString('ascii', 0, 4) === 'GIF8') return Mimes.ImageGif;
|
|
94
|
+
if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
95
|
+
return undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 파트 MIME: type > mime-types(name) > 바이트 판별 > 문자열이면 text/plain;utf-8 > octet-stream
|
|
100
|
+
*/
|
|
101
|
+
export const partType = (part: Part, fallback?: string): string => {
|
|
102
|
+
const p = isPartObject(part) ? part : { body: part };
|
|
103
|
+
if (p.type) return p.type;
|
|
104
|
+
if (p.name) {
|
|
105
|
+
const byName = mime.lookup(p.name);
|
|
106
|
+
if (byName) return mime.contentType(byName) || byName;
|
|
107
|
+
}
|
|
108
|
+
if (typeof p.body === 'string') return fallback ?? Mimes.TextPlainUtf8;
|
|
109
|
+
return sniff(Buffer.from(p.body.buffer, p.body.byteOffset, p.body.byteLength)) ?? fallback ?? Mimes.ApplicationOctetStream;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const isPartObject = (part: Part): part is Exclude<Part, Buffer | Uint8Array | string> =>
|
|
113
|
+
typeof part === 'object' && !(part instanceof Uint8Array);
|
|
114
|
+
|
|
115
|
+
const toBuffer = (body: Buffer | Uint8Array | string): Buffer =>
|
|
116
|
+
typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.isBuffer(body) ? body : Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* multipart 파트 하나: `--boundary\r\nContent-Type: …\r\nContent-Length: <바이트 수>\r\n[헤더…]\r\n\r\n<body>\r\n`
|
|
120
|
+
* Content-Length 는 문자열도 글자 수가 아니라 UTF-8 바이트 수.
|
|
121
|
+
*/
|
|
122
|
+
export const multipartPart = (part: Part, boundary: string, fallbackType?: string): Buffer => {
|
|
123
|
+
const p = isPartObject(part) ? part : { body: part };
|
|
124
|
+
const body = toBuffer(p.body);
|
|
125
|
+
let head = `--${boundary}\r\nContent-Type: ${partType(part, fallbackType)}\r\nContent-Length: ${body.length}\r\n`;
|
|
126
|
+
for (const [k, v] of Object.entries(p.headers ?? {})) head += `${oneLine(k)}: ${oneLine(v)}\r\n`;
|
|
127
|
+
return Buffer.concat([Buffer.from(head + '\r\n', 'latin1'), body, Buffer.from('\r\n')]);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** multipart 끝 표시 (multipart/mixed 처럼 끝이 있는 경우) */
|
|
131
|
+
export const multipartEnd = (boundary: string): Buffer => Buffer.from(`--${boundary}--\r\n`);
|
|
132
|
+
|
|
133
|
+
/** NDJSON 한 줄: JSON + \n (그냥 객체를 yield 하면 줄바꿈 없이 붙어서 받는 쪽이 못 나눔) */
|
|
134
|
+
export const ndjsonLine = (value: unknown): string => json(value) + '\n';
|
|
135
|
+
|
|
136
|
+
/** JSON.stringify(undefined) 는 undefined 라 그대로 붙이면 깨진 JSON — 배열 안처럼 null 로 */
|
|
137
|
+
const json = (value: unknown): string => JSON.stringify(value) ?? 'null';
|
|
138
|
+
|
|
139
|
+
/** RFC 7464 application/json-seq 레코드: RS(0x1E) + JSON + \n */
|
|
140
|
+
export const jsonSeqRecord = (value: unknown): string => '\x1e' + json(value) + '\n';
|
|
141
|
+
|
|
142
|
+
/** CSV 칸 하나 (RFC 4180): 쉼표·따옴표·줄바꿈·앞뒤 공백이 있으면 "…" 로 감싸고 " 는 "" */
|
|
143
|
+
export const csvCell = (value: unknown): string => {
|
|
144
|
+
if (value === null || value === undefined) return '';
|
|
145
|
+
const s = typeof value === 'string' ? value : value instanceof Date ? value.toISOString() : typeof value === 'object' ? json(value) : String(value);
|
|
146
|
+
return /[",\r\n]|^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** CSV 한 줄: 칸들 + CRLF */
|
|
150
|
+
export const csvRow = (cells: readonly unknown[]): string => cells.map(csvCell).join(',') + '\r\n';
|
|
151
|
+
|
|
152
|
+
/** text/plain 한 줄: 문자열은 그대로, 나머지는 JSON + \n */
|
|
153
|
+
export const textLine = (value: unknown): string => (typeof value === 'string' ? value : json(value)) + '\n';
|
|
154
|
+
|
|
155
|
+
/** RFC 2046: 1~70자, 허용 문자만, 공백으로 끝나지 않게 */
|
|
156
|
+
export const boundary = (value?: string): string => {
|
|
157
|
+
const b = value ?? `boundary-${randomBytes(12).toString('hex')}`;
|
|
158
|
+
if (!/^[0-9A-Za-z'()+_,\-./:=? ]{0,69}[0-9A-Za-z'()+_,\-./:=?]$/.test(b)) {
|
|
159
|
+
throw new Error(`invalid multipart boundary: ${JSON.stringify(b)}`);
|
|
160
|
+
}
|
|
161
|
+
return b;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// ───────────────────────── pull: 원본 generator 감싸기 ─────────────────────────
|
|
165
|
+
|
|
166
|
+
const toStream = <T>(gen: AsyncGenerator<T, void, undefined>, contentType: string, compress: boolean): Stream<T> =>
|
|
167
|
+
Object.assign(gen, { contentType, compress });
|
|
168
|
+
|
|
169
|
+
async function* encode<In, Out>(src: Source<In>, fn: (v: In) => Out): AsyncGenerator<Out, void, undefined> {
|
|
170
|
+
// 바깥이 return 되면(클라이언트 끊김) for await 가 원본에도 return 을 불러 원본의 finally 가 돈다
|
|
171
|
+
for await (const v of src as AsyncIterable<In>) yield fn(v);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 앞(start)·내용·뒤(end). 중간에 끊기면(return) end 는 안 나감 */
|
|
175
|
+
async function* framed<In, Out>(src: Source<In>, fn: (v: In) => Out, start?: Out, end?: Out): AsyncGenerator<Out, void, undefined> {
|
|
176
|
+
if (start !== undefined) yield start;
|
|
177
|
+
yield* encode(src, fn);
|
|
178
|
+
if (end !== undefined) yield end;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** JSON 배열 항목: 첫 항목 말고는 앞에 쉼표 (스트림마다 하나씩 만들어 씀) */
|
|
182
|
+
const jsonArrayItem = () => {
|
|
183
|
+
let n = 0;
|
|
184
|
+
return (v: unknown) => (n++ ? ',' : '') + json(v);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type CsvRow = readonly unknown[] | Record<string, unknown>;
|
|
188
|
+
export type CsvOption = {
|
|
189
|
+
/** 헤더 줄 + 객체 행을 이 순서로 칸에 맞춤 (없으면 객체는 Object.values 순서, 헤더 없음) */
|
|
190
|
+
columns?: string[];
|
|
191
|
+
/** UTF-8 BOM — Excel 이 한글 CSV 를 깨지지 않게 열려면 필요 */
|
|
192
|
+
bom?: boolean;
|
|
193
|
+
};
|
|
194
|
+
const csvEncoder = (opt: CsvOption) => (r: CsvRow) =>
|
|
195
|
+
csvRow(Array.isArray(r) ? r : opt.columns ? opt.columns.map(c => (r as Record<string, unknown>)[c]) : Object.values(r));
|
|
196
|
+
const csvStart = (opt: CsvOption): string | undefined =>
|
|
197
|
+
opt.bom || opt.columns ? (opt.bom ? '' : '') + (opt.columns ? csvRow(opt.columns) : '') : undefined;
|
|
198
|
+
const CsvType = `${Mimes.TextCsv}; charset=utf-8`;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* text/event-stream. heartbeatMs 를 주면 그동안 이벤트가 없을 때 `: ping` 을 끼워 연결 유지
|
|
202
|
+
* (프록시·브라우저는 오래 조용한 연결을 끊는다).
|
|
203
|
+
*/
|
|
204
|
+
export function sse(src: Source<SseEvent>, opt: { heartbeatMs?: number } = {}): Stream<string> {
|
|
205
|
+
const contentType = Mimes.TextEventStream;
|
|
206
|
+
if (!opt.heartbeatMs) return toStream(encode(src, sseChunk), contentType, true);
|
|
207
|
+
const ms = opt.heartbeatMs;
|
|
208
|
+
async function* withPing(): AsyncGenerator<string, void, undefined> {
|
|
209
|
+
const it = (Symbol.asyncIterator in (src as any)
|
|
210
|
+
? (src as AsyncIterable<SseEvent>)[Symbol.asyncIterator]()
|
|
211
|
+
: (src as Iterable<SseEvent>)[Symbol.iterator]()) as AsyncIterator<SseEvent> | Iterator<SseEvent>;
|
|
212
|
+
let pending: Promise<IteratorResult<SseEvent>> | undefined;
|
|
213
|
+
try {
|
|
214
|
+
while (true) {
|
|
215
|
+
pending ??= Promise.resolve(it.next());
|
|
216
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
const tick = new Promise<'ping'>(r => (timer = setTimeout(() => r('ping'), ms)));
|
|
218
|
+
const r = await Promise.race([pending, tick]);
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
if (r === 'ping') {
|
|
221
|
+
yield ssePing(); // 원본 next 는 그대로 기다림 (pending 유지)
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
pending = undefined;
|
|
225
|
+
if (r.done) return;
|
|
226
|
+
yield sseChunk(r.value);
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
await it.return?.();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return toStream(withPing(), contentType, true);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** multipart/x-mixed-replace (MJPEG 등 — 파트가 올 때마다 앞 것을 갈아끼움). 이미 압축된 내용이라 gzip 안 함 */
|
|
236
|
+
export function multipartReplace(src: Source<Part>, opt: { boundary?: string; type?: string } = {}): Stream<Buffer> {
|
|
237
|
+
const b = boundary(opt.boundary);
|
|
238
|
+
return toStream(encode(src, p => multipartPart(p, b, opt.type)), `multipart/x-mixed-replace; boundary=${b}`, false);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** multipart/mixed (끝이 있는 여러 파트). 원본이 끝나면 `--boundary--` */
|
|
242
|
+
export function multipartMixed(src: Source<Part>, opt: { boundary?: string; type?: string } = {}): Stream<Buffer> {
|
|
243
|
+
const b = boundary(opt.boundary);
|
|
244
|
+
return toStream(framed(src, p => multipartPart(p, b, opt.type), undefined, multipartEnd(b)), `multipart/mixed; boundary=${b}`, true);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** application/x-ndjson (한 줄에 JSON 하나) */
|
|
248
|
+
export function ndjson(src: Source<unknown>): Stream<string> {
|
|
249
|
+
return toStream(encode(src, ndjsonLine), Mimes.ApplicationXndjson, true);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** application/json-seq (RFC 7464, 레코드마다 RS 로 시작) */
|
|
253
|
+
export function jsonSeq(src: Source<unknown>): Stream<string> {
|
|
254
|
+
return toStream(encode(src, jsonSeqRecord), Mimes.ApplicationJsonSeq, true);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* application/json 배열을 항목마다 흘려보냄: `[` 항목 `,` 항목 … `]`.
|
|
259
|
+
* 받는 쪽은 다 받은 뒤 평범한 JSON 으로 파싱 (끝까지 받아야 유효한 JSON — 중간에 끊기면 깨진 JSON)
|
|
260
|
+
*/
|
|
261
|
+
export function jsonArray(src: Source<unknown>): Stream<string> {
|
|
262
|
+
return toStream(framed(src, jsonArrayItem(), '[', ']'), Mimes.ApplicationJson, true);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** text/csv; charset=utf-8 — 행은 배열 또는 객체(columns 순서). 줄바꿈 CRLF */
|
|
266
|
+
export function csv(src: Source<CsvRow>, opt: CsvOption = {}): Stream<string> {
|
|
267
|
+
return toStream(framed(src, csvEncoder(opt), csvStart(opt)), CsvType, true);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** text/plain; charset=utf-8 — 한 줄에 하나 (로그 tail 등) */
|
|
271
|
+
export function text(src: Source<unknown>): Stream<string> {
|
|
272
|
+
return toStream(encode(src, textLine), Mimes.TextPlainUtf8, true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ───────────────────────── push: 채널 ─────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* push 한 값을 큐에 담았다가 yield 하는 스트림.
|
|
279
|
+
* 직접 iterator 를 만드는 이유: 기다리는 중(await)에 클라이언트가 끊겨도 return() 이 바로 끝내고 onClose 를 부르게
|
|
280
|
+
* (async generator 는 await 중의 return 을 다음 yield 까지 미뤄서, 조용한 채널이면 정리가 안 됨).
|
|
281
|
+
*/
|
|
282
|
+
export function channel<In, Out>(
|
|
283
|
+
encodeFn: (v: In) => Out,
|
|
284
|
+
contentType: string,
|
|
285
|
+
compress: boolean,
|
|
286
|
+
opt: ChannelOption & { start?: () => Out; end?: () => Out; heartbeat?: { ms: number; chunk: () => Out } } = {}
|
|
287
|
+
): Channel<In, Out> {
|
|
288
|
+
const keep = opt.keep ?? 'queue';
|
|
289
|
+
const max = Math.max(1, opt.max ?? 1000);
|
|
290
|
+
const queue: In[] = [];
|
|
291
|
+
const closers: (() => void)[] = [];
|
|
292
|
+
let closed = false; // 더 안 받음 (쌓인 건 보냄)
|
|
293
|
+
let finished = false; // 완전히 끝남
|
|
294
|
+
let startSent = false;
|
|
295
|
+
let endSent = false;
|
|
296
|
+
let wake: (() => void) | undefined;
|
|
297
|
+
|
|
298
|
+
const finish = () => {
|
|
299
|
+
if (finished) return;
|
|
300
|
+
finished = closed = true;
|
|
301
|
+
queue.length = 0;
|
|
302
|
+
opt.signal?.removeEventListener('abort', onAbort);
|
|
303
|
+
wake?.();
|
|
304
|
+
for (const fn of closers.splice(0)) {
|
|
305
|
+
try { fn(); } catch { /* 정리 실패가 응답을 깨지 않게 */ }
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const onAbort = () => finish();
|
|
309
|
+
if (opt.signal?.aborted) closed = finished = true;
|
|
310
|
+
else opt.signal?.addEventListener('abort', onAbort, { once: true });
|
|
311
|
+
|
|
312
|
+
const next = async (): Promise<IteratorResult<Out, void>> => {
|
|
313
|
+
while (!finished) {
|
|
314
|
+
// 앞부분(JSON 배열 '[', CSV 헤더 등)은 첫 next 에 바로 — 아무것도 push 안 하고 close 해도 나감
|
|
315
|
+
if (opt.start && !startSent) {
|
|
316
|
+
startSent = true;
|
|
317
|
+
return { value: opt.start(), done: false };
|
|
318
|
+
}
|
|
319
|
+
if (queue.length) return { value: encodeFn(queue.shift() as In), done: false };
|
|
320
|
+
if (closed) {
|
|
321
|
+
if (opt.end && !endSent) {
|
|
322
|
+
endSent = true;
|
|
323
|
+
return { value: opt.end(), done: false };
|
|
324
|
+
}
|
|
325
|
+
finish();
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
329
|
+
const ping = await new Promise<boolean>(resolve => {
|
|
330
|
+
wake = () => resolve(false);
|
|
331
|
+
if (opt.heartbeat) timer = setTimeout(() => resolve(true), opt.heartbeat.ms);
|
|
332
|
+
});
|
|
333
|
+
wake = undefined;
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
if (ping && !finished && !queue.length && !closed) return { value: opt.heartbeat!.chunk(), done: false };
|
|
336
|
+
}
|
|
337
|
+
return { value: undefined, done: true };
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const iterator: Channel<In, Out> = {
|
|
341
|
+
contentType,
|
|
342
|
+
compress,
|
|
343
|
+
get closed() { return closed; },
|
|
344
|
+
push(value: In) {
|
|
345
|
+
if (closed) return false;
|
|
346
|
+
if (keep === 'latest') queue.length = 0;
|
|
347
|
+
else if (queue.length >= max) queue.shift();
|
|
348
|
+
queue.push(value);
|
|
349
|
+
wake?.();
|
|
350
|
+
return true;
|
|
351
|
+
},
|
|
352
|
+
close() {
|
|
353
|
+
closed = true;
|
|
354
|
+
wake?.();
|
|
355
|
+
},
|
|
356
|
+
onClose(fn: () => void) {
|
|
357
|
+
if (finished) fn();
|
|
358
|
+
else closers.push(fn);
|
|
359
|
+
},
|
|
360
|
+
next,
|
|
361
|
+
async return() {
|
|
362
|
+
finish();
|
|
363
|
+
return { value: undefined, done: true };
|
|
364
|
+
},
|
|
365
|
+
async throw(e?: unknown) {
|
|
366
|
+
finish();
|
|
367
|
+
throw e;
|
|
368
|
+
},
|
|
369
|
+
[Symbol.asyncIterator]() { return this; }
|
|
370
|
+
} as unknown as Channel<In, Out>;
|
|
371
|
+
// `await using` 정리 지원 (Symbol.asyncDispose 가 있는 런타임만 — AsyncGenerator 타입이 요구)
|
|
372
|
+
const dispose = (Symbol as any).asyncDispose as symbol | undefined;
|
|
373
|
+
if (dispose) (iterator as any)[dispose] = async () => finish();
|
|
374
|
+
return iterator;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export namespace sse {
|
|
378
|
+
/** push 로 SSE: out.push('hello') · out.push({ event: 'tick', data: { n: 1 } }) */
|
|
379
|
+
export const channel = (opt: ChannelOption & { heartbeatMs?: number } = {}): Channel<SseEvent, string> =>
|
|
380
|
+
ContentTypeGenerator.channel(sseChunk, Mimes.TextEventStream, true, {
|
|
381
|
+
...opt,
|
|
382
|
+
heartbeat: opt.heartbeatMs ? { ms: opt.heartbeatMs, chunk: ssePing } : undefined
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export namespace multipartReplace {
|
|
387
|
+
/** push 로 MJPEG 등: out.push(jpegBuffer) — 프레임은 보통 keep: 'latest' */
|
|
388
|
+
export const channel = (opt: ChannelOption & { boundary?: string; type?: string } = {}): Channel<Part, Buffer> => {
|
|
389
|
+
const b = boundary(opt.boundary);
|
|
390
|
+
return ContentTypeGenerator.channel((p: Part) => multipartPart(p, b, opt.type), `multipart/x-mixed-replace; boundary=${b}`, false, opt);
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export namespace multipartMixed {
|
|
395
|
+
export const channel = (opt: ChannelOption & { boundary?: string; type?: string } = {}): Channel<Part, Buffer> => {
|
|
396
|
+
const b = boundary(opt.boundary);
|
|
397
|
+
return ContentTypeGenerator.channel((p: Part) => multipartPart(p, b, opt.type), `multipart/mixed; boundary=${b}`, true, {
|
|
398
|
+
...opt,
|
|
399
|
+
end: () => multipartEnd(b)
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export namespace ndjson {
|
|
405
|
+
export const channel = (opt: ChannelOption = {}): Channel<unknown, string> =>
|
|
406
|
+
ContentTypeGenerator.channel(ndjsonLine, Mimes.ApplicationXndjson, true, opt);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export namespace jsonSeq {
|
|
410
|
+
export const channel = (opt: ChannelOption = {}): Channel<unknown, string> =>
|
|
411
|
+
ContentTypeGenerator.channel(jsonSeqRecord, Mimes.ApplicationJsonSeq, true, opt);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export namespace jsonArray {
|
|
415
|
+
/** close 해야 `]` 가 나가서 유효한 JSON 이 됨 */
|
|
416
|
+
export const channel = (opt: ChannelOption = {}): Channel<unknown, string> =>
|
|
417
|
+
ContentTypeGenerator.channel(jsonArrayItem(), Mimes.ApplicationJson, true, { ...opt, start: () => '[', end: () => ']' });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export namespace csv {
|
|
421
|
+
export const channel = (opt: ChannelOption & CsvOption = {}): Channel<CsvRow, string> => {
|
|
422
|
+
const start = csvStart(opt);
|
|
423
|
+
return ContentTypeGenerator.channel(csvEncoder(opt), CsvType, true, { ...opt, start: start === undefined ? undefined : () => start });
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export namespace text {
|
|
428
|
+
export const channel = (opt: ChannelOption = {}): Channel<unknown, string> =>
|
|
429
|
+
ContentTypeGenerator.channel(textLine, Mimes.TextPlainUtf8, true, opt);
|
|
430
|
+
}
|
|
431
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { FileUtils } from '@dooboostore/core-node';
|
|
|
22
22
|
import { ConvertUtils as CoreConvertUtils } from '@dooboostore/core';
|
|
23
23
|
import { ConvertUtils } from '@dooboostore/core-node';
|
|
24
24
|
import {HttpMethod} from "../codes";
|
|
25
|
+
import { BadRequestError } from '../errors/BadRequestError';
|
|
25
26
|
|
|
26
27
|
type Config = { sessionManager?: SessionManager; option: HttpServerOption };
|
|
27
28
|
|
|
@@ -346,7 +347,14 @@ export class RequestResponse {
|
|
|
346
347
|
}
|
|
347
348
|
|
|
348
349
|
async reqBodyJsonData<T>(): Promise<T> {
|
|
349
|
-
|
|
350
|
+
const raw = await this.reqBodyStringData();
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(raw);
|
|
353
|
+
} catch (e: any) {
|
|
354
|
+
// Malformed JSON is a client error (400), not a server crash (500).
|
|
355
|
+
// Express-compatible: surface the parse failure as Bad Request.
|
|
356
|
+
throw new BadRequestError({ message: `Invalid JSON body: ${e?.message ?? e}` });
|
|
357
|
+
}
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
async reqBodyFormUrlData<T>(): Promise<T> {
|
|
@@ -372,7 +380,15 @@ export class RequestResponse {
|
|
|
372
380
|
|
|
373
381
|
async reqBodyReqJsonBody(): Promise<ReqJsonBody> {
|
|
374
382
|
const data = await this.reqBodyStringData();
|
|
375
|
-
|
|
383
|
+
if (!data) {
|
|
384
|
+
return Object.assign(new ReqJsonBody(), {});
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
return Object.assign(new ReqJsonBody(), JSON.parse(data));
|
|
388
|
+
} catch (e: any) {
|
|
389
|
+
// Malformed JSON is a client error (400), not a server crash (500).
|
|
390
|
+
throw new BadRequestError({ message: `Invalid JSON body: ${e?.message ?? e}` });
|
|
391
|
+
}
|
|
376
392
|
}
|
|
377
393
|
|
|
378
394
|
resBodyJsonData<T>(): T | null {
|
|
@@ -564,11 +580,12 @@ export class RequestResponse {
|
|
|
564
580
|
* - 문자열/Buffer 는 그대로, 그 외는 JSON.stringify
|
|
565
581
|
* - Accept-Encoding 에 gzip 이 있으면 gzip 스트림으로 압축하고, 청크마다 Z_SYNC_FLUSH 로 바로 내보낸다
|
|
566
582
|
* (flush 없이 쓰면 zlib 내부 버퍼에 모였다가 늦게 나가서 SSE 실시간성이 깨짐)
|
|
583
|
+
* option.compress === false 면 압축 안 함 (MJPEG 처럼 이미 압축된 내용 — CPU만 쓰고, gzip 못 푸는 클라이언트도 있음)
|
|
567
584
|
* - 클라이언트가 끊으면 source.return() 으로 제너레이터를 종료시켜 finally 가 돌게 한다
|
|
568
585
|
*/
|
|
569
|
-
async resStream(source: Iterable<any> | AsyncIterable<any
|
|
586
|
+
async resStream(source: Iterable<any> | AsyncIterable<any>, option: { compress?: boolean } = {}) {
|
|
570
587
|
const res = this.res;
|
|
571
|
-
const gz = this.reqHeaderFirst(HttpHeaders.AcceptEncoding)?.includes('gzip') ? createGzip() : undefined;
|
|
588
|
+
const gz = option.compress !== false && this.reqHeaderFirst(HttpHeaders.AcceptEncoding)?.includes('gzip') ? createGzip() : undefined;
|
|
572
589
|
if (gz) {
|
|
573
590
|
res.setHeader(HttpHeaders.ContentEncoding, 'gzip');
|
|
574
591
|
res.removeHeader(HttpHeaders.ContentLength);
|
|
@@ -8,6 +8,7 @@ import { RequestResponse } from '../models/RequestResponse';
|
|
|
8
8
|
import { SimpleBootHttpServer } from '../SimpleBootHttpServer';
|
|
9
9
|
import { TransactionManager } from '@dooboostore/core';
|
|
10
10
|
import { WebSocketEndPoint } from '../endpoints/WebSocketEndPoint';
|
|
11
|
+
import { NotFoundError } from '../errors/NotFoundError';
|
|
11
12
|
|
|
12
13
|
export type Listen = { port?: number, hostname?: string, backlog?: number, listeningListener?: (server: SimpleBootHttpServer, httpServer: HttpServer | HttpsServer) => void };
|
|
13
14
|
|
|
@@ -107,7 +108,13 @@ export class HttpServerOption extends SimOption {
|
|
|
107
108
|
);
|
|
108
109
|
this.globalAdvice = globalAdvice;
|
|
109
110
|
this.fileUploadTempPath = fileUploadTempPath;
|
|
110
|
-
|
|
111
|
+
// Unmatched routes must not fall through to "200 OK with empty body"
|
|
112
|
+
// like before. Default to 404 (Express-compatible); users can still
|
|
113
|
+
// override with their own noSuchRouteEndPointMappingThrow.
|
|
114
|
+
this.noSuchRouteEndPointMappingThrow =
|
|
115
|
+
noSuchRouteEndPointMappingThrow ??
|
|
116
|
+
((rr: RequestResponse) =>
|
|
117
|
+
new NotFoundError({ message: `no route ${rr.reqMethod?.() ?? ''} ${rr.reqUrlPathName ?? ''}`.trim() }));
|
|
111
118
|
this.transactionManagerFactory = transactionManagerFactory;
|
|
112
119
|
}
|
|
113
120
|
|