@volter/world-core 3.0.163 → 3.0.165
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/dist/generated/pack-facts.json +44 -3660
- package/dist/pack-facts.cjs +4 -4
- package/dist/src/browser-session.d.ts +7 -1
- package/dist/src/browser-session.js +21 -10
- package/dist/src/browser.d.ts +4 -0
- package/dist/src/browser.js +10 -0
- package/dist/src/derived-core.d.ts +46 -6
- package/dist/src/derived-core.js +60 -13
- package/dist/src/derived-real.js +159 -5
- package/dist/src/derived.d.ts +1 -1
- package/dist/src/events.d.ts +6 -3
- package/dist/src/events.js +52 -7
- package/dist/src/executor.d.ts +2 -2
- package/dist/src/executor.js +13 -0
- package/dist/src/file-response.d.ts +6 -2
- package/dist/src/file-response.js +29 -5
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/smart-http-client.d.ts +28 -0
- package/dist/src/git/smart-http-client.js +95 -0
- package/dist/src/git/smart-http.d.ts +3 -0
- package/dist/src/git/smart-http.js +9 -0
- package/dist/src/head.d.ts +16 -1
- package/dist/src/head.js +52 -7
- package/dist/src/html.d.ts +29 -0
- package/dist/src/html.js +64 -0
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +5 -1
- package/dist/src/managed-database.d.ts +4 -2
- package/dist/src/managed-database.js +1 -0
- package/dist/src/media-header.d.ts +34 -0
- package/dist/src/media-header.js +129 -0
- package/dist/src/pack-fetch.d.ts +24 -3
- package/dist/src/pack-fetch.js +155 -96
- package/dist/src/pack-paths.d.ts +2 -3
- package/dist/src/pack-paths.js +3 -13
- package/dist/src/packRegistry.d.ts +17 -1
- package/dist/src/request-body.d.ts +2 -3
- package/dist/src/request-body.js +2 -3
- package/dist/src/runtime.d.ts +4 -1
- package/dist/src/runtime.js +5 -1
- package/dist/src/serve-http.d.ts +2 -2
- package/dist/src/serve-http.js +104 -19
- package/dist/src/smtp-server.d.ts +78 -0
- package/dist/src/smtp-server.js +160 -0
- package/dist/src/smtp.d.ts +29 -11
- package/dist/src/smtp.js +221 -52
- package/generated/pack-facts.json +44 -3660
- package/pack-facts.cjs +4 -4
- package/package.json +4 -2
- package/src/browser-session.ts +17 -5
- package/src/browser.ts +10 -0
- package/src/derived-core.ts +77 -19
- package/src/derived-real.ts +131 -4
- package/src/derived.ts +2 -2
- package/src/events.ts +54 -9
- package/src/executor.ts +13 -2
- package/src/file-response.ts +28 -6
- package/src/git/index.ts +1 -0
- package/src/git/smart-http-client.ts +99 -0
- package/src/git/smart-http.ts +7 -0
- package/src/head.ts +37 -6
- package/src/html.ts +72 -0
- package/src/index.ts +6 -2
- package/src/managed-database.ts +4 -3
- package/src/media-header.ts +108 -0
- package/src/pack-fetch.ts +142 -87
- package/src/pack-paths.ts +3 -11
- package/src/packRegistry.ts +9 -1
- package/src/request-body.ts +2 -3
- package/src/runtime.ts +5 -1
- package/src/serve-http.ts +81 -19
- package/src/smtp-server.ts +166 -0
- package/src/smtp.ts +199 -53
package/src/serve-http.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The HTTP server seam of the kernel (docs/contributing/architecture.md#the-serve-seam): a fetch handler on
|
|
2
2
|
// a port, in the shape every `Bun.serve` call already has, so the serve path meets the runtime in
|
|
3
|
-
// ONE place.
|
|
4
|
-
// as a standard Request and the handler's Response written back. Async, because binding a port is
|
|
3
|
+
// ONE place. Ordinary HTTP uses `node:http`; requested WebSockets retain Bun's native backend.
|
|
4
|
+
// The request is rebuilt as a standard Request and the handler's Response written back. Async, because binding a port is
|
|
5
5
|
// asynchronous on Node — Bun's synchronous bind is the special case.
|
|
6
6
|
//
|
|
7
7
|
// Nothing here runs at module scope (the served-handler registry is made on first use), and `node:http` is reached through `process.getBuiltinModule`
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TwinStream } from './twin-fetch.ts';
|
|
12
12
|
import { worldBootAnswer } from './world-boot.ts';
|
|
13
|
+
import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from './request-body.ts';
|
|
13
14
|
|
|
14
15
|
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
15
16
|
const socketLibrary = () => import('ws');
|
|
@@ -41,7 +42,7 @@ export type ServeHttpOptions = {
|
|
|
41
42
|
fetch: HttpHandler;
|
|
42
43
|
/** seconds a connection may idle (Bun honors it; Node's keep-alive timeout is set from it) */
|
|
43
44
|
idleTimeout?: number;
|
|
44
|
-
/** Explicit transport limit in bytes; absent,
|
|
45
|
+
/** Explicit transport limit in bytes; absent, 128 MiB on both runtimes. */
|
|
45
46
|
maxRequestBodySize?: number;
|
|
46
47
|
/** what a thrown handler answers with; absent, a 500 carrying the error (as Bun.serve) */
|
|
47
48
|
error?: (error: Error) => Response | Promise<Response>;
|
|
@@ -101,7 +102,7 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
101
102
|
const hostname = options.hostname ?? '127.0.0.1';
|
|
102
103
|
const urlHost = options.hostname ?? '127.0.0.1';
|
|
103
104
|
const bun = bunRuntime();
|
|
104
|
-
if (bun) {
|
|
105
|
+
if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
|
|
105
106
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
106
107
|
// this seam alike, so the seam never decorates twice on Bun
|
|
107
108
|
const upgrade = options.upgrade;
|
|
@@ -184,17 +185,19 @@ const STREAM_HIGH_WATER = 4 * 1024 * 1024;
|
|
|
184
185
|
/** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
|
|
185
186
|
* socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
|
|
186
187
|
* `net` reached at call time, never imported. A stream that declares `maxConnections` (a live ingest) is held to it. */
|
|
187
|
-
export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream; maxConnections?: number }): Promise<{ port: number; stop: () => void }> {
|
|
188
|
+
export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream; maxConnections?: number }): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
188
189
|
const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
189
190
|
const net = typeof get === 'function' ? get('node:net') as typeof import('node:net') | undefined : undefined;
|
|
190
191
|
if (!net || typeof net.createServer !== 'function') throw new StreamListenerUnavailable('this runtime has no TCP listener (node:net)');
|
|
191
192
|
const cap = options.maxConnections ?? (options.stream as { maxConnections?: number }).maxConnections;
|
|
192
193
|
let open = 0;
|
|
194
|
+
const accepted = new Set<import('node:net').Socket>();
|
|
193
195
|
const server = net.createServer((socket) => {
|
|
194
196
|
// past its cap a connection is closed before the stream sees it, so connections that never get anywhere cannot pile up
|
|
195
197
|
if (cap !== undefined && open >= cap) { socket.destroy(); return; }
|
|
196
198
|
open += 1;
|
|
197
|
-
|
|
199
|
+
accepted.add(socket);
|
|
200
|
+
socket.once('close', () => { open -= 1; accepted.delete(socket); });
|
|
198
201
|
const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
|
|
199
202
|
// a connection that says what waits (`backlog`: whole messages its work has not reached) is read no further past the
|
|
200
203
|
// high-water mark until that work has settled, so a peer faster than the twin (a live ingest pushed faster than real
|
|
@@ -211,7 +214,13 @@ export async function serveStream(options: { port?: number; hostname?: string; s
|
|
|
211
214
|
});
|
|
212
215
|
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, options.hostname ?? '127.0.0.1', () => resolve()); });
|
|
213
216
|
const address = server.address();
|
|
214
|
-
|
|
217
|
+
// stop is the listener's real retirement: it settles once the listener is closed and every connection it accepted has
|
|
218
|
+
// ended (each is closed here, so none holds the close open)
|
|
219
|
+
const stop = (): Promise<void> => new Promise<void>((resolve) => {
|
|
220
|
+
server.close(() => resolve());
|
|
221
|
+
for (const socket of accepted) socket.destroy();
|
|
222
|
+
});
|
|
223
|
+
return { port: typeof address === 'object' && address ? address.port : options.port ?? 0, stop };
|
|
215
224
|
}
|
|
216
225
|
function builtin<T>(name: string): T {
|
|
217
226
|
const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
@@ -221,7 +230,8 @@ function builtin<T>(name: string): T {
|
|
|
221
230
|
|
|
222
231
|
async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHost: string }): Promise<HttpServer> {
|
|
223
232
|
const http = builtin<NodeHttp>('node:http');
|
|
224
|
-
const { Readable } = builtin<NodeStream>('node:stream');
|
|
233
|
+
const { Readable, Transform } = builtin<NodeStream>('node:stream');
|
|
234
|
+
const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
|
|
225
235
|
if ('websocket' in options) throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
|
|
226
236
|
const protocol = options.tls ? 'https' : 'http';
|
|
227
237
|
const factory = (handler: import('node:http').RequestListener) => options.tls
|
|
@@ -245,23 +255,35 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
245
255
|
} catch { /* hostile thrown values are still reported by stage */ }
|
|
246
256
|
try { process.stderr.write(`serveHttp ${stage}: ${detail.slice(0, 512)}\n`); } catch { /* stderr is best-effort */ }
|
|
247
257
|
};
|
|
248
|
-
const active = new Set<
|
|
258
|
+
const active = new Set<() => void>();
|
|
249
259
|
// Node's Request signal is dependent on the source signal. Neither a pending
|
|
250
260
|
// handler nor a streaming response necessarily keeps the Request alive, so
|
|
251
261
|
// retain it until the whole HTTP response path (including body cleanup) ends.
|
|
252
262
|
const activeRequests = new Set<Request>();
|
|
253
263
|
const server = factory((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
254
264
|
const controller = new AbortController();
|
|
255
|
-
|
|
256
|
-
const
|
|
265
|
+
const socket = req.socket;
|
|
266
|
+
const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
|
|
267
|
+
let incoming: import('node:stream').Transform | undefined;
|
|
268
|
+
let flushingBodyRefusal = false;
|
|
269
|
+
const abort = () => {
|
|
270
|
+
if (!controller.signal.aborted) controller.abort();
|
|
271
|
+
incoming?.destroy(controller.signal.reason);
|
|
272
|
+
if (!flushingBodyRefusal) {
|
|
273
|
+
req.destroy();
|
|
274
|
+
if (!res.writableFinished) res.destroy();
|
|
275
|
+
socket.destroy();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
active.add(abort);
|
|
257
279
|
const onRequestClose = () => { if (!req.complete) abort(); };
|
|
258
|
-
const onResponseClose = () => { if (!res.writableFinished) abort(); };
|
|
280
|
+
const onResponseClose = () => { if (!res.writableFinished) { flushingBodyRefusal = false; abort(); } };
|
|
259
281
|
const onSocketClose = () => { if (!res.writableFinished) abort(); };
|
|
260
282
|
req.on('aborted', abort);
|
|
261
283
|
req.on('close', onRequestClose);
|
|
262
284
|
req.on('error', abort);
|
|
263
|
-
|
|
264
|
-
|
|
285
|
+
socket.on('close', onSocketClose);
|
|
286
|
+
socket.on('error', abort);
|
|
265
287
|
res.on('close', onResponseClose);
|
|
266
288
|
res.on('error', abort);
|
|
267
289
|
const disconnected = () => controller.signal.aborted || res.destroyed;
|
|
@@ -288,17 +310,50 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
288
310
|
if (disconnected()) closed();
|
|
289
311
|
});
|
|
290
312
|
let request: Request | undefined;
|
|
313
|
+
let bodyTooLarge = false;
|
|
314
|
+
const rejectBody = () => {
|
|
315
|
+
if (bodyTooLarge) return;
|
|
316
|
+
bodyTooLarge = true;
|
|
317
|
+
const error = new RequestBodyTooLargeError();
|
|
318
|
+
if (incoming) req.unpipe(incoming);
|
|
319
|
+
req.pause();
|
|
320
|
+
if (!res.headersSent && !res.destroyed) {
|
|
321
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
322
|
+
flushingBodyRefusal = true;
|
|
323
|
+
res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
|
|
324
|
+
res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
|
|
325
|
+
res.end(error.message);
|
|
326
|
+
} else if (!res.destroyed) res.destroy(error);
|
|
327
|
+
abort();
|
|
328
|
+
};
|
|
329
|
+
const inputSettled = new Promise<void>((resolve) => {
|
|
330
|
+
const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
|
|
331
|
+
req.once('end', settled);
|
|
332
|
+
req.once('close', settled);
|
|
333
|
+
});
|
|
291
334
|
const handle = async () => {
|
|
292
335
|
const method = req.method ?? 'GET';
|
|
336
|
+
if (Number(req.headers['content-length']) > requestLimit) { rejectBody(); return; }
|
|
293
337
|
let response: Response;
|
|
294
338
|
try {
|
|
295
339
|
const headers = new Headers();
|
|
296
340
|
for (const [k, v] of Object.entries(req.headers)) { if (v === undefined) continue; if (Array.isArray(v)) for (const one of v) headers.append(k, one); else headers.set(k, v); }
|
|
297
341
|
const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${(server.address() as { port: number } | null)?.port ?? 0}`}${req.url ?? '/'}`;
|
|
298
342
|
const withBody = method !== 'GET' && method !== 'HEAD';
|
|
299
|
-
|
|
343
|
+
let received = 0;
|
|
344
|
+
incoming = new Transform({ transform(chunk: Buffer, _encoding, callback) {
|
|
345
|
+
received += chunk.byteLength;
|
|
346
|
+
if (received > requestLimit) { rejectBody(); callback(new RequestBodyTooLargeError()); }
|
|
347
|
+
else callback(null, chunk);
|
|
348
|
+
} });
|
|
349
|
+
const inputClosed = () => { if (!req.readableEnded) abort(); };
|
|
350
|
+
incoming.on('error', inputClosed);
|
|
351
|
+
incoming.once('close', inputClosed);
|
|
352
|
+
const init: RequestInit & { duplex?: 'half' } = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming) as unknown as ReadableStream, duplex: 'half' } : {}) };
|
|
300
353
|
request = new Request(url, init);
|
|
301
354
|
activeRequests.add(request);
|
|
355
|
+
req.pipe(incoming);
|
|
356
|
+
if (!withBody) incoming.resume();
|
|
302
357
|
response = await options.fetch(request);
|
|
303
358
|
} catch (error) {
|
|
304
359
|
if (disconnected()) return;
|
|
@@ -340,6 +395,7 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
340
395
|
if (disconnected()) { await cancelOwnedReader(); return; }
|
|
341
396
|
res.statusCode = response.status;
|
|
342
397
|
for (const [k, v] of response.headers) { if (k !== 'set-cookie') res.setHeader(k, v); }
|
|
398
|
+
res.strictContentLength = response.headers.has('content-length');
|
|
343
399
|
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
344
400
|
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
345
401
|
if (reader) {
|
|
@@ -382,12 +438,18 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
382
438
|
if (!res.destroyed) res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
383
439
|
}).then(async () => {
|
|
384
440
|
await responseSettled;
|
|
385
|
-
|
|
441
|
+
// A runtime may retire its native body forwarding when the response ends.
|
|
442
|
+
// Close unfinished input after the response flushes; a consumed upload keeps its connection.
|
|
443
|
+
if (hasInputBody && !incoming?.readableEnded) abort();
|
|
444
|
+
else incoming?.resume();
|
|
445
|
+
await inputSettled;
|
|
446
|
+
if (incoming) { req.unpipe(incoming); incoming.destroy(); }
|
|
447
|
+
active.delete(abort);
|
|
386
448
|
req.off('aborted', abort);
|
|
387
449
|
req.off('close', onRequestClose);
|
|
388
450
|
req.off('error', abort);
|
|
389
|
-
|
|
390
|
-
|
|
451
|
+
socket.off('close', onSocketClose);
|
|
452
|
+
socket.off('error', abort);
|
|
391
453
|
res.off('close', onResponseClose);
|
|
392
454
|
res.off('error', abort);
|
|
393
455
|
});
|
|
@@ -415,7 +477,7 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
415
477
|
const port = (server.address() as { port: number }).port;
|
|
416
478
|
return {
|
|
417
479
|
hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
|
|
418
|
-
stop: (closeActive = true) => new Promise<void>((resolve) => { if (closeActive) { for (const
|
|
480
|
+
stop: (closeActive = true) => new Promise<void>((resolve) => { if (closeActive) { for (const abort of active) abort(); for (const peer of socketServer?.clients ?? []) peer.terminate(); } socketServer?.close(); server.close(() => { if (closeActive && active.size) reportCleanup('forced stop left pending handler cleanup', `${active.size} request(s)`); resolve(); }); if (closeActive) server.closeAllConnections(); }),
|
|
419
481
|
};
|
|
420
482
|
}
|
|
421
483
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// THE KERNEL'S SMTP SERVER MACHINE (`smtpServer`, from `@volter/world-core`): RFC 5321's mechanics every pack that serves an
|
|
2
|
+
// SMTP server shares (a relay's submission server, a mailbox provider's receiving server: ADR 0032 §4), behind the kernel's
|
|
3
|
+
// stream boundary (TwinStream). It frames a connection's bytes into commands, a message's data and an AUTH response,
|
|
4
|
+
// carries them to the pack's front over the pack's own fetch (a private bridge the pack's key signs), and parses the
|
|
5
|
+
// RFC's command arguments. What a server answers, which senders and recipients it takes and what it keeps stay in each
|
|
6
|
+
// pack: its replies, its policy and its writes. The kernel names no vendor. docs/contributing/architecture.md, "Other
|
|
7
|
+
// wires: lanes, headers and line protocols".
|
|
8
|
+
// source: RFC 5321 §4.1.1.4 "In particular, the sequence \"<LF>.<LF>\" (bare line feeds, without carriage returns) MUST NOT be treated as equivalent to <CRLF>.<CRLF> as the end of mail data indication."
|
|
9
|
+
// source: RFC 5321 §4.5.2 "If the first character is a period and there are other characters on the line, the first character is deleted."
|
|
10
|
+
import type { HandlerContext } from './derived-core.ts';
|
|
11
|
+
import type { TwinStream } from './twin-fetch.ts';
|
|
12
|
+
|
|
13
|
+
/** What a server's front answers the stream: its reply lines, the moved session, and whether the stream reads a
|
|
14
|
+
* message's data next (`data`), an AUTH response (`auth`) or commands, or closes. */
|
|
15
|
+
export type SmtpAnswer<S> = { replies: string[]; session: S; mode: 'command' | 'data' | 'auth'; close?: boolean };
|
|
16
|
+
|
|
17
|
+
/** The bridge between a connection and the pack's front: the header that carries the pack's key, the key, a fresh
|
|
18
|
+
* session, and the pack's answer when its front cannot answer (its words for a local error). */
|
|
19
|
+
export type SmtpBridge<S> = { header: string; key: () => string; fresh: () => S; failure: (session: S) => SmtpAnswer<S> };
|
|
20
|
+
|
|
21
|
+
/** A connection's bytes framed as SMTP frames them, each piece posted to the pack's front (`smtpFront`) through its
|
|
22
|
+
* fetch with the session the connection carries: commands are lines ended by CRLF; after a 354, the message's lines up
|
|
23
|
+
* to the lone dot, their leading dot removed; after a 334, one line of AUTH response. Pipelined commands are answered
|
|
24
|
+
* in order. */
|
|
25
|
+
export function smtpStream<S>(fetch: (request: Request) => Response | Promise<Response>, bridge: SmtpBridge<S>): TwinStream {
|
|
26
|
+
return (sink, peer) => {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let buffer = '';
|
|
30
|
+
let session = bridge.fresh();
|
|
31
|
+
let mode: SmtpAnswer<S>['mode'] = 'command';
|
|
32
|
+
let data: string[] = [];
|
|
33
|
+
let closed = false;
|
|
34
|
+
let chain: Promise<void> = Promise.resolve();
|
|
35
|
+
|
|
36
|
+
const post = async (body: Record<string, unknown>): Promise<void> => {
|
|
37
|
+
if (closed) return;
|
|
38
|
+
let a: SmtpAnswer<S>;
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(new Request('http://smtp.wire/', { method: 'POST', headers: { 'content-type': 'application/json', [bridge.header]: bridge.key(), 'x-forwarded-for': peer }, body: JSON.stringify({ session, ...body }) }));
|
|
41
|
+
a = res.ok ? (await res.json()) as SmtpAnswer<S> : bridge.failure(session);
|
|
42
|
+
if (!Array.isArray(a.replies) || !a.session || !['command', 'data', 'auth'].includes(a.mode)) a = bridge.failure(session);
|
|
43
|
+
} catch { a = bridge.failure(session); }
|
|
44
|
+
session = a.session;
|
|
45
|
+
mode = a.mode;
|
|
46
|
+
sink.write(encoder.encode(a.replies.map((r) => `${r}\r\n`).join('')));
|
|
47
|
+
if (a.close) { closed = true; sink.end(); }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const line = async (text: string): Promise<void> => {
|
|
51
|
+
if (mode === 'data') {
|
|
52
|
+
if (text === '.') { const message = data.join('\r\n') + (data.length ? '\r\n' : ''); data = []; mode = 'command'; await post({ data: message }); return; }
|
|
53
|
+
data.push(text.startsWith('.') ? text.slice(1) : text);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (mode === 'auth') { await post({ response: text }); return; }
|
|
57
|
+
await post({ line: decoder.decode(Uint8Array.from(text, (c) => c.charCodeAt(0))) });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const pump = (): void => {
|
|
61
|
+
let at: number;
|
|
62
|
+
while ((at = buffer.indexOf('\r\n')) >= 0) {
|
|
63
|
+
const text = buffer.slice(0, at);
|
|
64
|
+
buffer = buffer.slice(at + 2);
|
|
65
|
+
chain = chain.then(() => line(text));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
chain = chain.then(() => post({ open: true }));
|
|
70
|
+
return {
|
|
71
|
+
data(chunk) { if (closed) return; buffer += Array.from(chunk, (b) => String.fromCharCode(b)).join(''); pump(); },
|
|
72
|
+
settled: () => chain,
|
|
73
|
+
close() { closed = true; },
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a pack's front does with each piece the stream carries: the greeting, a command line, a message's data, an AUTH
|
|
79
|
+
* response (a server that takes none leaves it out). */
|
|
80
|
+
type SmtpServer<S> = {
|
|
81
|
+
open(): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
82
|
+
command(session: S, line: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
83
|
+
data(session: S, data: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
84
|
+
response?(session: S, response: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The pack's front (its `around`): a request the stream signed with the pack's key is a piece of a connection, answered
|
|
88
|
+
* by the pack's server; any other request is the pack's own (its doors and screens). */
|
|
89
|
+
export async function smtpFront<S>(ctx: HandlerContext, next: (request?: Request) => Promise<Response>, bridge: SmtpBridge<S>, server: SmtpServer<S>): Promise<Response> {
|
|
90
|
+
const request = ctx.call.request;
|
|
91
|
+
if (!request.headers.has(bridge.header)) return next();
|
|
92
|
+
if (request.headers.get(bridge.header) !== bridge.key()) return new Response('not the server\'s wire', { status: 403 });
|
|
93
|
+
const w = (ctx.body ?? {}) as { session?: S; open?: boolean; line?: string; data?: string; response?: string };
|
|
94
|
+
const s = w.session ?? bridge.fresh();
|
|
95
|
+
if (w.open) return Response.json(await server.open());
|
|
96
|
+
if (w.data !== undefined) return Response.json(await server.data(s, w.data));
|
|
97
|
+
if (w.response !== undefined && server.response) return Response.json(await server.response(s, w.response));
|
|
98
|
+
return Response.json(await server.command(s, String(w.line ?? '')));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A command's verb (upper-cased) and its argument. */
|
|
102
|
+
export function smtpCommand(line: string): { verb: string; arg: string } {
|
|
103
|
+
const [verb = '', ...rest] = line.split(' ');
|
|
104
|
+
return { verb: verb.toUpperCase(), arg: rest.join(' ') };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The session machine's word on a command from where a session stands (the manifest's states): a refusal's reply, or
|
|
108
|
+
* undefined to go on. */
|
|
109
|
+
export function smtpOrder(ctx: Pick<HandlerContext, 'legal'>, machine: string, stage: string, op: string, to?: string): string | undefined {
|
|
110
|
+
const refusal = ctx.legal(machine, 'stage', op, stage, to);
|
|
111
|
+
return refusal ? `${refusal.status} ${String(refusal.code)} ${refusal.message}` : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** EHLO's multi-line 250 reply: the server's name, then each keyword. */
|
|
115
|
+
// source: RFC 5321 §4.1.1.1 "ehlo-ok-rsp"
|
|
116
|
+
export const smtpEhlo = (keywords: string[]): string[] => keywords.map((k, i) => `250${i === keywords.length - 1 ? ' ' : '-'}${k}`);
|
|
117
|
+
|
|
118
|
+
/** An address in angle brackets (`<a@b>`, `<>` for the null sender), or undefined when it is not one. */
|
|
119
|
+
function smtpAddress(arg: string): string | undefined {
|
|
120
|
+
const m = /^<([^<>\s]*)>$/.exec(arg.trim());
|
|
121
|
+
if (!m) return undefined;
|
|
122
|
+
const a = m[1]!;
|
|
123
|
+
if (a === '') return '';
|
|
124
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(a) ? a : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An address's domain, lower-cased. */
|
|
128
|
+
export const smtpDomain = (address: string): string => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
|
|
129
|
+
|
|
130
|
+
/** MAIL's argument: the sender and its SIZE, BODY and SMTPUTF8 parameters, or why it is refused (the pack words it). */
|
|
131
|
+
// source: RFC 5321 §4.1.1.2 "MAIL FROM:<reverse-path> [SP <mail-parameters> ] <CRLF>"
|
|
132
|
+
export function smtpMailFrom(arg: string): { from: string; size?: number; smtputf8: boolean } | { refused: 'syntax' | 'sender' | 'size' } | { refused: 'parameter'; parameter: string } {
|
|
133
|
+
const m = /^FROM:\s*(<[^>]*>)((?:\s+\S+)*)\s*$/i.exec(arg);
|
|
134
|
+
if (!m) return { refused: 'syntax' };
|
|
135
|
+
const from = smtpAddress(m[1]!);
|
|
136
|
+
if (from === undefined) return { refused: 'sender' };
|
|
137
|
+
let size: number | undefined;
|
|
138
|
+
let smtputf8 = false;
|
|
139
|
+
for (const parameter of m[2]!.trim().split(/\s+/).filter(Boolean)) {
|
|
140
|
+
const [k = '', v] = parameter.split('=');
|
|
141
|
+
if (k.toUpperCase() === 'SIZE') { if (!/^\d+$/.test(v ?? '')) return { refused: 'size' }; size = Number(v); }
|
|
142
|
+
else if (k.toUpperCase() === 'BODY' && ['7BIT', '8BITMIME'].includes((v ?? '').toUpperCase())) continue;
|
|
143
|
+
else if (k.toUpperCase() === 'SMTPUTF8' && v === undefined) smtputf8 = true;
|
|
144
|
+
else return { refused: 'parameter', parameter };
|
|
145
|
+
}
|
|
146
|
+
return { from, ...(size !== undefined ? { size } : {}), smtputf8 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** RCPT's argument: the recipient, or why it is refused (the pack words it). */
|
|
150
|
+
// source: RFC 5321 §4.1.1.3 "RCPT TO:<forward-path> [ SP <rcpt-parameters> ] <CRLF>"
|
|
151
|
+
export function smtpRcptTo(arg: string): { to: string } | { refused: 'syntax' | 'recipient' } | { refused: 'parameter'; parameter: string } {
|
|
152
|
+
const m = /^TO:\s*(<[^>]*>)(?:\s+\S+)*\s*$/i.exec(arg);
|
|
153
|
+
if (!m) return { refused: 'syntax' };
|
|
154
|
+
// source: RFC 5321 §4.1.1.11 "command, it will return code 555."
|
|
155
|
+
if (/^TO:\s*<[^>]*>\s+\S/i.test(arg)) return { refused: 'parameter', parameter: arg.slice(arg.indexOf('>') + 1).trim() };
|
|
156
|
+
const to = smtpAddress(m[1]!);
|
|
157
|
+
return to ? { to } : { refused: 'recipient' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA): the session's `stage`,
|
|
161
|
+
* `greeted` and an empty `rcpt`, with whatever of the session the pack keeps across transactions (`kept`). */
|
|
162
|
+
// source: RFC 5321 §4.1.1.5 "This command specifies that the current mail transaction will be aborted."
|
|
163
|
+
export function smtpCleared<S extends { stage: string; rcpt: string[]; greeted?: 'helo' | 'ehlo' }>(s: S, stage: string, kept: Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'>): Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'> & { stage: string; rcpt: string[]; greeted?: 'helo' | 'ehlo' } {
|
|
164
|
+
const greeted = stage === 'helo' || stage === 'ehlo' ? stage : s.greeted;
|
|
165
|
+
return { ...kept, stage, rcpt: [], ...(greeted ? { greeted } : {}) };
|
|
166
|
+
}
|