@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/dist/src/serve-http.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
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`
|
|
8
8
|
// rather than a static import: this module rides into browser client bundles through the kernel's
|
|
9
9
|
// entrypoint, where a static `node:` import is the 2026-09-06 class of break.
|
|
10
10
|
import { worldBootAnswer } from "./world-boot.js";
|
|
11
|
+
import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from "./request-body.js";
|
|
11
12
|
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
12
13
|
const socketLibrary = () => import('ws');
|
|
13
14
|
export async function connectWebSocket(url, headers, protocols = []) {
|
|
@@ -56,7 +57,7 @@ export async function serveHttp(options) {
|
|
|
56
57
|
const hostname = options.hostname ?? '127.0.0.1';
|
|
57
58
|
const urlHost = options.hostname ?? '127.0.0.1';
|
|
58
59
|
const bun = bunRuntime();
|
|
59
|
-
if (bun) {
|
|
60
|
+
if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
|
|
60
61
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
61
62
|
// this seam alike, so the seam never decorates twice on Bun
|
|
62
63
|
const upgrade = options.upgrade;
|
|
@@ -152,6 +153,7 @@ export async function serveStream(options) {
|
|
|
152
153
|
throw new StreamListenerUnavailable('this runtime has no TCP listener (node:net)');
|
|
153
154
|
const cap = options.maxConnections ?? options.stream.maxConnections;
|
|
154
155
|
let open = 0;
|
|
156
|
+
const accepted = new Set();
|
|
155
157
|
const server = net.createServer((socket) => {
|
|
156
158
|
// past its cap a connection is closed before the stream sees it, so connections that never get anywhere cannot pile up
|
|
157
159
|
if (cap !== undefined && open >= cap) {
|
|
@@ -159,7 +161,8 @@ export async function serveStream(options) {
|
|
|
159
161
|
return;
|
|
160
162
|
}
|
|
161
163
|
open += 1;
|
|
162
|
-
|
|
164
|
+
accepted.add(socket);
|
|
165
|
+
socket.once('close', () => { open -= 1; accepted.delete(socket); });
|
|
163
166
|
const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
|
|
164
167
|
// a connection that says what waits (`backlog`: whole messages its work has not reached) is read no further past the
|
|
165
168
|
// high-water mark until that work has settled, so a peer faster than the twin (a live ingest pushed faster than real
|
|
@@ -181,7 +184,14 @@ export async function serveStream(options) {
|
|
|
181
184
|
});
|
|
182
185
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, options.hostname ?? '127.0.0.1', () => resolve()); });
|
|
183
186
|
const address = server.address();
|
|
184
|
-
|
|
187
|
+
// stop is the listener's real retirement: it settles once the listener is closed and every connection it accepted has
|
|
188
|
+
// ended (each is closed here, so none holds the close open)
|
|
189
|
+
const stop = () => new Promise((resolve) => {
|
|
190
|
+
server.close(() => resolve());
|
|
191
|
+
for (const socket of accepted)
|
|
192
|
+
socket.destroy();
|
|
193
|
+
});
|
|
194
|
+
return { port: typeof address === 'object' && address ? address.port : options.port ?? 0, stop };
|
|
185
195
|
}
|
|
186
196
|
function builtin(name) {
|
|
187
197
|
const get = process.getBuiltinModule;
|
|
@@ -191,7 +201,8 @@ function builtin(name) {
|
|
|
191
201
|
}
|
|
192
202
|
async function serveOnNode(options) {
|
|
193
203
|
const http = builtin('node:http');
|
|
194
|
-
const { Readable } = builtin('node:stream');
|
|
204
|
+
const { Readable, Transform } = builtin('node:stream');
|
|
205
|
+
const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
|
|
195
206
|
if ('websocket' in options)
|
|
196
207
|
throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
|
|
197
208
|
const protocol = options.tls ? 'https' : 'http';
|
|
@@ -230,20 +241,35 @@ async function serveOnNode(options) {
|
|
|
230
241
|
const activeRequests = new Set();
|
|
231
242
|
const server = factory((req, res) => {
|
|
232
243
|
const controller = new AbortController();
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
244
|
+
const socket = req.socket;
|
|
245
|
+
const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
|
|
246
|
+
let incoming;
|
|
247
|
+
let flushingBodyRefusal = false;
|
|
248
|
+
const abort = () => {
|
|
249
|
+
if (!controller.signal.aborted)
|
|
250
|
+
controller.abort();
|
|
251
|
+
incoming?.destroy(controller.signal.reason);
|
|
252
|
+
if (!flushingBodyRefusal) {
|
|
253
|
+
req.destroy();
|
|
254
|
+
if (!res.writableFinished)
|
|
255
|
+
res.destroy();
|
|
256
|
+
socket.destroy();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
active.add(abort);
|
|
236
260
|
const onRequestClose = () => { if (!req.complete)
|
|
237
261
|
abort(); };
|
|
238
|
-
const onResponseClose = () => { if (!res.writableFinished)
|
|
239
|
-
|
|
262
|
+
const onResponseClose = () => { if (!res.writableFinished) {
|
|
263
|
+
flushingBodyRefusal = false;
|
|
264
|
+
abort();
|
|
265
|
+
} };
|
|
240
266
|
const onSocketClose = () => { if (!res.writableFinished)
|
|
241
267
|
abort(); };
|
|
242
268
|
req.on('aborted', abort);
|
|
243
269
|
req.on('close', onRequestClose);
|
|
244
270
|
req.on('error', abort);
|
|
245
|
-
|
|
246
|
-
|
|
271
|
+
socket.on('close', onSocketClose);
|
|
272
|
+
socket.on('error', abort);
|
|
247
273
|
res.on('close', onResponseClose);
|
|
248
274
|
res.on('error', abort);
|
|
249
275
|
const disconnected = () => controller.signal.aborted || res.destroyed;
|
|
@@ -279,8 +305,38 @@ async function serveOnNode(options) {
|
|
|
279
305
|
closed();
|
|
280
306
|
});
|
|
281
307
|
let request;
|
|
308
|
+
let bodyTooLarge = false;
|
|
309
|
+
const rejectBody = () => {
|
|
310
|
+
if (bodyTooLarge)
|
|
311
|
+
return;
|
|
312
|
+
bodyTooLarge = true;
|
|
313
|
+
const error = new RequestBodyTooLargeError();
|
|
314
|
+
if (incoming)
|
|
315
|
+
req.unpipe(incoming);
|
|
316
|
+
req.pause();
|
|
317
|
+
if (!res.headersSent && !res.destroyed) {
|
|
318
|
+
for (const name of res.getHeaderNames())
|
|
319
|
+
res.removeHeader(name);
|
|
320
|
+
flushingBodyRefusal = true;
|
|
321
|
+
res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
|
|
322
|
+
res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
|
|
323
|
+
res.end(error.message);
|
|
324
|
+
}
|
|
325
|
+
else if (!res.destroyed)
|
|
326
|
+
res.destroy(error);
|
|
327
|
+
abort();
|
|
328
|
+
};
|
|
329
|
+
const inputSettled = new Promise((resolve) => {
|
|
330
|
+
const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
|
|
331
|
+
req.once('end', settled);
|
|
332
|
+
req.once('close', settled);
|
|
333
|
+
});
|
|
282
334
|
const handle = async () => {
|
|
283
335
|
const method = req.method ?? 'GET';
|
|
336
|
+
if (Number(req.headers['content-length']) > requestLimit) {
|
|
337
|
+
rejectBody();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
284
340
|
let response;
|
|
285
341
|
try {
|
|
286
342
|
const headers = new Headers();
|
|
@@ -295,9 +351,26 @@ async function serveOnNode(options) {
|
|
|
295
351
|
}
|
|
296
352
|
const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${server.address()?.port ?? 0}`}${req.url ?? '/'}`;
|
|
297
353
|
const withBody = method !== 'GET' && method !== 'HEAD';
|
|
298
|
-
|
|
354
|
+
let received = 0;
|
|
355
|
+
incoming = new Transform({ transform(chunk, _encoding, callback) {
|
|
356
|
+
received += chunk.byteLength;
|
|
357
|
+
if (received > requestLimit) {
|
|
358
|
+
rejectBody();
|
|
359
|
+
callback(new RequestBodyTooLargeError());
|
|
360
|
+
}
|
|
361
|
+
else
|
|
362
|
+
callback(null, chunk);
|
|
363
|
+
} });
|
|
364
|
+
const inputClosed = () => { if (!req.readableEnded)
|
|
365
|
+
abort(); };
|
|
366
|
+
incoming.on('error', inputClosed);
|
|
367
|
+
incoming.once('close', inputClosed);
|
|
368
|
+
const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming), duplex: 'half' } : {}) };
|
|
299
369
|
request = new Request(url, init);
|
|
300
370
|
activeRequests.add(request);
|
|
371
|
+
req.pipe(incoming);
|
|
372
|
+
if (!withBody)
|
|
373
|
+
incoming.resume();
|
|
301
374
|
response = await options.fetch(request);
|
|
302
375
|
}
|
|
303
376
|
catch (error) {
|
|
@@ -365,6 +438,7 @@ async function serveOnNode(options) {
|
|
|
365
438
|
if (k !== 'set-cookie')
|
|
366
439
|
res.setHeader(k, v);
|
|
367
440
|
}
|
|
441
|
+
res.strictContentLength = response.headers.has('content-length');
|
|
368
442
|
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
369
443
|
if (cookies.length)
|
|
370
444
|
res.setHeader('set-cookie', cookies);
|
|
@@ -424,12 +498,23 @@ async function serveOnNode(options) {
|
|
|
424
498
|
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
425
499
|
}).then(async () => {
|
|
426
500
|
await responseSettled;
|
|
427
|
-
|
|
501
|
+
// A runtime may retire its native body forwarding when the response ends.
|
|
502
|
+
// Close unfinished input after the response flushes; a consumed upload keeps its connection.
|
|
503
|
+
if (hasInputBody && !incoming?.readableEnded)
|
|
504
|
+
abort();
|
|
505
|
+
else
|
|
506
|
+
incoming?.resume();
|
|
507
|
+
await inputSettled;
|
|
508
|
+
if (incoming) {
|
|
509
|
+
req.unpipe(incoming);
|
|
510
|
+
incoming.destroy();
|
|
511
|
+
}
|
|
512
|
+
active.delete(abort);
|
|
428
513
|
req.off('aborted', abort);
|
|
429
514
|
req.off('close', onRequestClose);
|
|
430
515
|
req.off('error', abort);
|
|
431
|
-
|
|
432
|
-
|
|
516
|
+
socket.off('close', onSocketClose);
|
|
517
|
+
socket.off('error', abort);
|
|
433
518
|
res.off('close', onResponseClose);
|
|
434
519
|
res.off('error', abort);
|
|
435
520
|
});
|
|
@@ -462,8 +547,8 @@ async function serveOnNode(options) {
|
|
|
462
547
|
return {
|
|
463
548
|
hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
|
|
464
549
|
stop: (closeActive = true) => new Promise((resolve) => { if (closeActive) {
|
|
465
|
-
for (const
|
|
466
|
-
|
|
550
|
+
for (const abort of active)
|
|
551
|
+
abort();
|
|
467
552
|
for (const peer of socketServer?.clients ?? [])
|
|
468
553
|
peer.terminate();
|
|
469
554
|
} socketServer?.close(); server.close(() => { if (closeActive && active.size)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { HandlerContext } from './derived-core.js';
|
|
2
|
+
import type { TwinStream } from './twin-fetch.js';
|
|
3
|
+
/** What a server's front answers the stream: its reply lines, the moved session, and whether the stream reads a
|
|
4
|
+
* message's data next (`data`), an AUTH response (`auth`) or commands, or closes. */
|
|
5
|
+
export type SmtpAnswer<S> = {
|
|
6
|
+
replies: string[];
|
|
7
|
+
session: S;
|
|
8
|
+
mode: 'command' | 'data' | 'auth';
|
|
9
|
+
close?: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** The bridge between a connection and the pack's front: the header that carries the pack's key, the key, a fresh
|
|
12
|
+
* session, and the pack's answer when its front cannot answer (its words for a local error). */
|
|
13
|
+
export type SmtpBridge<S> = {
|
|
14
|
+
header: string;
|
|
15
|
+
key: () => string;
|
|
16
|
+
fresh: () => S;
|
|
17
|
+
failure: (session: S) => SmtpAnswer<S>;
|
|
18
|
+
};
|
|
19
|
+
/** A connection's bytes framed as SMTP frames them, each piece posted to the pack's front (`smtpFront`) through its
|
|
20
|
+
* fetch with the session the connection carries: commands are lines ended by CRLF; after a 354, the message's lines up
|
|
21
|
+
* to the lone dot, their leading dot removed; after a 334, one line of AUTH response. Pipelined commands are answered
|
|
22
|
+
* in order. */
|
|
23
|
+
export declare function smtpStream<S>(fetch: (request: Request) => Response | Promise<Response>, bridge: SmtpBridge<S>): TwinStream;
|
|
24
|
+
/** What a pack's front does with each piece the stream carries: the greeting, a command line, a message's data, an AUTH
|
|
25
|
+
* response (a server that takes none leaves it out). */
|
|
26
|
+
type SmtpServer<S> = {
|
|
27
|
+
open(): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
28
|
+
command(session: S, line: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
29
|
+
data(session: S, data: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
30
|
+
response?(session: S, response: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
31
|
+
};
|
|
32
|
+
/** The pack's front (its `around`): a request the stream signed with the pack's key is a piece of a connection, answered
|
|
33
|
+
* by the pack's server; any other request is the pack's own (its doors and screens). */
|
|
34
|
+
export declare function smtpFront<S>(ctx: HandlerContext, next: (request?: Request) => Promise<Response>, bridge: SmtpBridge<S>, server: SmtpServer<S>): Promise<Response>;
|
|
35
|
+
/** A command's verb (upper-cased) and its argument. */
|
|
36
|
+
export declare function smtpCommand(line: string): {
|
|
37
|
+
verb: string;
|
|
38
|
+
arg: string;
|
|
39
|
+
};
|
|
40
|
+
/** The session machine's word on a command from where a session stands (the manifest's states): a refusal's reply, or
|
|
41
|
+
* undefined to go on. */
|
|
42
|
+
export declare function smtpOrder(ctx: Pick<HandlerContext, 'legal'>, machine: string, stage: string, op: string, to?: string): string | undefined;
|
|
43
|
+
/** EHLO's multi-line 250 reply: the server's name, then each keyword. */
|
|
44
|
+
export declare const smtpEhlo: (keywords: string[]) => string[];
|
|
45
|
+
/** An address's domain, lower-cased. */
|
|
46
|
+
export declare const smtpDomain: (address: string) => string;
|
|
47
|
+
/** MAIL's argument: the sender and its SIZE, BODY and SMTPUTF8 parameters, or why it is refused (the pack words it). */
|
|
48
|
+
export declare function smtpMailFrom(arg: string): {
|
|
49
|
+
from: string;
|
|
50
|
+
size?: number;
|
|
51
|
+
smtputf8: boolean;
|
|
52
|
+
} | {
|
|
53
|
+
refused: 'syntax' | 'sender' | 'size';
|
|
54
|
+
} | {
|
|
55
|
+
refused: 'parameter';
|
|
56
|
+
parameter: string;
|
|
57
|
+
};
|
|
58
|
+
/** RCPT's argument: the recipient, or why it is refused (the pack words it). */
|
|
59
|
+
export declare function smtpRcptTo(arg: string): {
|
|
60
|
+
to: string;
|
|
61
|
+
} | {
|
|
62
|
+
refused: 'syntax' | 'recipient';
|
|
63
|
+
} | {
|
|
64
|
+
refused: 'parameter';
|
|
65
|
+
parameter: string;
|
|
66
|
+
};
|
|
67
|
+
/** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA): the session's `stage`,
|
|
68
|
+
* `greeted` and an empty `rcpt`, with whatever of the session the pack keeps across transactions (`kept`). */
|
|
69
|
+
export declare function smtpCleared<S extends {
|
|
70
|
+
stage: string;
|
|
71
|
+
rcpt: string[];
|
|
72
|
+
greeted?: 'helo' | 'ehlo';
|
|
73
|
+
}>(s: S, stage: string, kept: Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'>): Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'> & {
|
|
74
|
+
stage: string;
|
|
75
|
+
rcpt: string[];
|
|
76
|
+
greeted?: 'helo' | 'ehlo';
|
|
77
|
+
};
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** A connection's bytes framed as SMTP frames them, each piece posted to the pack's front (`smtpFront`) through its
|
|
2
|
+
* fetch with the session the connection carries: commands are lines ended by CRLF; after a 354, the message's lines up
|
|
3
|
+
* to the lone dot, their leading dot removed; after a 334, one line of AUTH response. Pipelined commands are answered
|
|
4
|
+
* in order. */
|
|
5
|
+
export function smtpStream(fetch, bridge) {
|
|
6
|
+
return (sink, peer) => {
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const decoder = new TextDecoder();
|
|
9
|
+
let buffer = '';
|
|
10
|
+
let session = bridge.fresh();
|
|
11
|
+
let mode = 'command';
|
|
12
|
+
let data = [];
|
|
13
|
+
let closed = false;
|
|
14
|
+
let chain = Promise.resolve();
|
|
15
|
+
const post = async (body) => {
|
|
16
|
+
if (closed)
|
|
17
|
+
return;
|
|
18
|
+
let a;
|
|
19
|
+
try {
|
|
20
|
+
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 }) }));
|
|
21
|
+
a = res.ok ? (await res.json()) : bridge.failure(session);
|
|
22
|
+
if (!Array.isArray(a.replies) || !a.session || !['command', 'data', 'auth'].includes(a.mode))
|
|
23
|
+
a = bridge.failure(session);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
a = bridge.failure(session);
|
|
27
|
+
}
|
|
28
|
+
session = a.session;
|
|
29
|
+
mode = a.mode;
|
|
30
|
+
sink.write(encoder.encode(a.replies.map((r) => `${r}\r\n`).join('')));
|
|
31
|
+
if (a.close) {
|
|
32
|
+
closed = true;
|
|
33
|
+
sink.end();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const line = async (text) => {
|
|
37
|
+
if (mode === 'data') {
|
|
38
|
+
if (text === '.') {
|
|
39
|
+
const message = data.join('\r\n') + (data.length ? '\r\n' : '');
|
|
40
|
+
data = [];
|
|
41
|
+
mode = 'command';
|
|
42
|
+
await post({ data: message });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
data.push(text.startsWith('.') ? text.slice(1) : text);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'auth') {
|
|
49
|
+
await post({ response: text });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await post({ line: decoder.decode(Uint8Array.from(text, (c) => c.charCodeAt(0))) });
|
|
53
|
+
};
|
|
54
|
+
const pump = () => {
|
|
55
|
+
let at;
|
|
56
|
+
while ((at = buffer.indexOf('\r\n')) >= 0) {
|
|
57
|
+
const text = buffer.slice(0, at);
|
|
58
|
+
buffer = buffer.slice(at + 2);
|
|
59
|
+
chain = chain.then(() => line(text));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
chain = chain.then(() => post({ open: true }));
|
|
63
|
+
return {
|
|
64
|
+
data(chunk) { if (closed)
|
|
65
|
+
return; buffer += Array.from(chunk, (b) => String.fromCharCode(b)).join(''); pump(); },
|
|
66
|
+
settled: () => chain,
|
|
67
|
+
close() { closed = true; },
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The pack's front (its `around`): a request the stream signed with the pack's key is a piece of a connection, answered
|
|
72
|
+
* by the pack's server; any other request is the pack's own (its doors and screens). */
|
|
73
|
+
export async function smtpFront(ctx, next, bridge, server) {
|
|
74
|
+
const request = ctx.call.request;
|
|
75
|
+
if (!request.headers.has(bridge.header))
|
|
76
|
+
return next();
|
|
77
|
+
if (request.headers.get(bridge.header) !== bridge.key())
|
|
78
|
+
return new Response('not the server\'s wire', { status: 403 });
|
|
79
|
+
const w = (ctx.body ?? {});
|
|
80
|
+
const s = w.session ?? bridge.fresh();
|
|
81
|
+
if (w.open)
|
|
82
|
+
return Response.json(await server.open());
|
|
83
|
+
if (w.data !== undefined)
|
|
84
|
+
return Response.json(await server.data(s, w.data));
|
|
85
|
+
if (w.response !== undefined && server.response)
|
|
86
|
+
return Response.json(await server.response(s, w.response));
|
|
87
|
+
return Response.json(await server.command(s, String(w.line ?? '')));
|
|
88
|
+
}
|
|
89
|
+
/** A command's verb (upper-cased) and its argument. */
|
|
90
|
+
export function smtpCommand(line) {
|
|
91
|
+
const [verb = '', ...rest] = line.split(' ');
|
|
92
|
+
return { verb: verb.toUpperCase(), arg: rest.join(' ') };
|
|
93
|
+
}
|
|
94
|
+
/** The session machine's word on a command from where a session stands (the manifest's states): a refusal's reply, or
|
|
95
|
+
* undefined to go on. */
|
|
96
|
+
export function smtpOrder(ctx, machine, stage, op, to) {
|
|
97
|
+
const refusal = ctx.legal(machine, 'stage', op, stage, to);
|
|
98
|
+
return refusal ? `${refusal.status} ${String(refusal.code)} ${refusal.message}` : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** EHLO's multi-line 250 reply: the server's name, then each keyword. */
|
|
101
|
+
// source: RFC 5321 §4.1.1.1 "ehlo-ok-rsp"
|
|
102
|
+
export const smtpEhlo = (keywords) => keywords.map((k, i) => `250${i === keywords.length - 1 ? ' ' : '-'}${k}`);
|
|
103
|
+
/** An address in angle brackets (`<a@b>`, `<>` for the null sender), or undefined when it is not one. */
|
|
104
|
+
function smtpAddress(arg) {
|
|
105
|
+
const m = /^<([^<>\s]*)>$/.exec(arg.trim());
|
|
106
|
+
if (!m)
|
|
107
|
+
return undefined;
|
|
108
|
+
const a = m[1];
|
|
109
|
+
if (a === '')
|
|
110
|
+
return '';
|
|
111
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(a) ? a : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** An address's domain, lower-cased. */
|
|
114
|
+
export const smtpDomain = (address) => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
|
|
115
|
+
/** MAIL's argument: the sender and its SIZE, BODY and SMTPUTF8 parameters, or why it is refused (the pack words it). */
|
|
116
|
+
// source: RFC 5321 §4.1.1.2 "MAIL FROM:<reverse-path> [SP <mail-parameters> ] <CRLF>"
|
|
117
|
+
export function smtpMailFrom(arg) {
|
|
118
|
+
const m = /^FROM:\s*(<[^>]*>)((?:\s+\S+)*)\s*$/i.exec(arg);
|
|
119
|
+
if (!m)
|
|
120
|
+
return { refused: 'syntax' };
|
|
121
|
+
const from = smtpAddress(m[1]);
|
|
122
|
+
if (from === undefined)
|
|
123
|
+
return { refused: 'sender' };
|
|
124
|
+
let size;
|
|
125
|
+
let smtputf8 = false;
|
|
126
|
+
for (const parameter of m[2].trim().split(/\s+/).filter(Boolean)) {
|
|
127
|
+
const [k = '', v] = parameter.split('=');
|
|
128
|
+
if (k.toUpperCase() === 'SIZE') {
|
|
129
|
+
if (!/^\d+$/.test(v ?? ''))
|
|
130
|
+
return { refused: 'size' };
|
|
131
|
+
size = Number(v);
|
|
132
|
+
}
|
|
133
|
+
else if (k.toUpperCase() === 'BODY' && ['7BIT', '8BITMIME'].includes((v ?? '').toUpperCase()))
|
|
134
|
+
continue;
|
|
135
|
+
else if (k.toUpperCase() === 'SMTPUTF8' && v === undefined)
|
|
136
|
+
smtputf8 = true;
|
|
137
|
+
else
|
|
138
|
+
return { refused: 'parameter', parameter };
|
|
139
|
+
}
|
|
140
|
+
return { from, ...(size !== undefined ? { size } : {}), smtputf8 };
|
|
141
|
+
}
|
|
142
|
+
/** RCPT's argument: the recipient, or why it is refused (the pack words it). */
|
|
143
|
+
// source: RFC 5321 §4.1.1.3 "RCPT TO:<forward-path> [ SP <rcpt-parameters> ] <CRLF>"
|
|
144
|
+
export function smtpRcptTo(arg) {
|
|
145
|
+
const m = /^TO:\s*(<[^>]*>)(?:\s+\S+)*\s*$/i.exec(arg);
|
|
146
|
+
if (!m)
|
|
147
|
+
return { refused: 'syntax' };
|
|
148
|
+
// source: RFC 5321 §4.1.1.11 "command, it will return code 555."
|
|
149
|
+
if (/^TO:\s*<[^>]*>\s+\S/i.test(arg))
|
|
150
|
+
return { refused: 'parameter', parameter: arg.slice(arg.indexOf('>') + 1).trim() };
|
|
151
|
+
const to = smtpAddress(m[1]);
|
|
152
|
+
return to ? { to } : { refused: 'recipient' };
|
|
153
|
+
}
|
|
154
|
+
/** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA): the session's `stage`,
|
|
155
|
+
* `greeted` and an empty `rcpt`, with whatever of the session the pack keeps across transactions (`kept`). */
|
|
156
|
+
// source: RFC 5321 §4.1.1.5 "This command specifies that the current mail transaction will be aborted."
|
|
157
|
+
export function smtpCleared(s, stage, kept) {
|
|
158
|
+
const greeted = stage === 'helo' || stage === 'ehlo' ? stage : s.greeted;
|
|
159
|
+
return { ...kept, stage, rcpt: [], ...(greeted ? { greeted } : {}) };
|
|
160
|
+
}
|
package/dist/src/smtp.d.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import type { TwinStream } from './twin-fetch.js';
|
|
2
|
+
/** One mail, from its sender to its recipients: the sender's own message bytes, or a vendor's HTML mail the kernel
|
|
3
|
+
* composes (GoTrue's links). */
|
|
4
|
+
export type Mail = {
|
|
5
5
|
from: string;
|
|
6
|
+
to: string[];
|
|
7
|
+
} & ({
|
|
8
|
+
raw: Uint8Array | string;
|
|
9
|
+
} | {
|
|
6
10
|
senderName?: string;
|
|
7
|
-
};
|
|
8
|
-
/** One mail: its recipient, subject and HTML body. */
|
|
9
|
-
export type Mail = {
|
|
10
|
-
to: string;
|
|
11
11
|
subject: string;
|
|
12
12
|
html: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
});
|
|
15
|
+
/** One recipient's answer: the receiving server's reply to its `RCPT`, or to the message when it accepted the
|
|
16
|
+
* recipient; or the kernel's own refusal when no server receives the recipient's domain. */
|
|
17
|
+
export type MailReply = {
|
|
18
|
+
to: string;
|
|
19
|
+
code: number;
|
|
20
|
+
reply: string;
|
|
21
|
+
};
|
|
22
|
+
/** A mail server this process answers itself (a journey walk's companion over the walk's own World): its SMTP side
|
|
23
|
+
* port's byte-stream door (`create<Name>TwinStream`), for the domains `receives` names (its host rules). */
|
|
24
|
+
type InProcessMailServer = {
|
|
25
|
+
vendor: string;
|
|
26
|
+
receives: (domain: string) => boolean;
|
|
27
|
+
stream: TwinStream;
|
|
13
28
|
};
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
export declare function
|
|
29
|
+
/** The mail servers this process answers itself, consulted before the instance's; returns those set before, which the
|
|
30
|
+
* caller puts back. */
|
|
31
|
+
export declare function setInProcessMailServers(servers: ReadonlyArray<InProcessMailServer>): ReadonlyArray<InProcessMailServer>;
|
|
32
|
+
/** Hand one mail to each recipient's mailbox provider; every recipient's reply, in the order given. */
|
|
33
|
+
export declare function sendMail(mail: Mail): Promise<MailReply[]>;
|
|
34
|
+
export {};
|