@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.
Files changed (46) hide show
  1. package/README.md +58 -1
  2. package/dist/cjs/SimpleBootHttpServer.js +6 -3
  3. package/dist/cjs/SimpleBootHttpServer.js.map +2 -2
  4. package/dist/cjs/codes/Mimes.js +2 -0
  5. package/dist/cjs/codes/Mimes.js.map +2 -2
  6. package/dist/cjs/generators/ContentTypeGenerator.js +320 -0
  7. package/dist/cjs/generators/ContentTypeGenerator.js.map +7 -0
  8. package/dist/cjs/index.js +2 -0
  9. package/dist/cjs/index.js.map +2 -2
  10. package/dist/cjs/models/RequestResponse.js +18 -4
  11. package/dist/cjs/models/RequestResponse.js.map +2 -2
  12. package/dist/cjs/option/HttpServerOption.js +2 -1
  13. package/dist/cjs/option/HttpServerOption.js.map +2 -2
  14. package/dist/esm/SimpleBootHttpServer.js +6 -3
  15. package/dist/esm/SimpleBootHttpServer.js.map +2 -2
  16. package/dist/esm/codes/Mimes.js +2 -0
  17. package/dist/esm/codes/Mimes.js.map +2 -2
  18. package/dist/esm/generators/ContentTypeGenerator.js +287 -0
  19. package/dist/esm/generators/ContentTypeGenerator.js.map +7 -0
  20. package/dist/esm/index.js +1 -0
  21. package/dist/esm/index.js.map +2 -2
  22. package/dist/esm/models/RequestResponse.js +18 -4
  23. package/dist/esm/models/RequestResponse.js.map +2 -2
  24. package/dist/esm/option/HttpServerOption.js +2 -1
  25. package/dist/esm/option/HttpServerOption.js.map +2 -2
  26. package/dist/esm-bundle/dooboostore-simple-boot-http-server.esm.js +678 -5240
  27. package/dist/esm-bundle/dooboostore-simple-boot-http-server.esm.js.map +4 -4
  28. package/dist/types/SimpleBootHttpServer.d.ts.map +1 -1
  29. package/dist/types/codes/Mimes.d.ts +2 -0
  30. package/dist/types/codes/Mimes.d.ts.map +1 -1
  31. package/dist/types/generators/ContentTypeGenerator.d.ts +172 -0
  32. package/dist/types/generators/ContentTypeGenerator.d.ts.map +1 -0
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/types/models/RequestResponse.d.ts +4 -1
  36. package/dist/types/models/RequestResponse.d.ts.map +1 -1
  37. package/dist/types/option/HttpServerOption.d.ts.map +1 -1
  38. package/dist/umd-bundle/dooboostore-simple-boot-http-server.umd.js +678 -5240
  39. package/dist/umd-bundle/dooboostore-simple-boot-http-server.umd.js.map +4 -4
  40. package/package.json +4 -4
  41. package/src/SimpleBootHttpServer.ts +7 -3
  42. package/src/codes/Mimes.ts +2 -0
  43. package/src/generators/ContentTypeGenerator.ts +431 -0
  44. package/src/index.ts +1 -0
  45. package/src/models/RequestResponse.ts +21 -4
  46. 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.30",
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/core-web": "1.0.14",
103
- "@dooboostore/simple-boot": "1.0.59",
102
+ "@dooboostore/dom-render": "1.0.115",
104
103
  "@dooboostore/core-node": "1.0.14",
105
- "@dooboostore/dom-render": "1.0.115"
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
- if (resConfig?.contentType) {
347
- headers[HttpHeaders.ContentType] = resConfig.contentType;
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;
@@ -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
@@ -3,6 +3,7 @@ export * from './decorators';
3
3
  export * from './endpoints';
4
4
  export * from './errors';
5
5
  export * from './filters';
6
+ export * from './generators/ContentTypeGenerator';
6
7
  export * from './proxy';
7
8
  export * from './inject';
8
9
  export * from './lifecycle';
@@ -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
- return JSON.parse(await this.reqBodyStringData());
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
- return Object.assign(new ReqJsonBody(), data ? JSON.parse(data) : {});
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
- this.noSuchRouteEndPointMappingThrow = noSuchRouteEndPointMappingThrow;
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