@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/executor.ts
CHANGED
|
@@ -248,8 +248,8 @@ async function awsSigV4Headers(input: {
|
|
|
248
248
|
/** `vendorHosts`: the pack's declared vendor host rules (its descriptor's `hosts`). An absolute https URL
|
|
249
249
|
* on one of them — an exact `host` rule whose `pathPattern` the path matches — is a second host of the
|
|
250
250
|
* same vendor that takes the credential (LinkedIn's image uploads on www.linkedin.com/dms-uploads,
|
|
251
|
-
* Instagram's rupload host); it is sent credentialed like an anchored call
|
|
252
|
-
*
|
|
251
|
+
* Instagram's rupload host); it is sent credentialed like an anchored call, as HTTP Basic when a rule naming
|
|
252
|
+
* it names a `basic` user. Any other absolute URL is refused. */
|
|
253
253
|
/** Where a credential is kept (the head's and refresh's root: head.ts rootCustody): `key` names the credential stably
|
|
254
254
|
* across rotations and anew when another is sealed in its place (the token cache is keyed by it, not by the secret's value), `open` reads it as sealed now, `seal` keeps a rotated one. */
|
|
255
255
|
/** `unsealed`: where a rotation the seal failed to keep is recorded durably (the field and the cause, never a value), since
|
|
@@ -326,6 +326,8 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
326
326
|
let target: URL;
|
|
327
327
|
// the vendor's own URL, where a loopback root stands in for it: what a signature is made over
|
|
328
328
|
let vendorUrl: URL | undefined;
|
|
329
|
+
// the user a host that takes the token as HTTP Basic's password names it under (HostRule.basic)
|
|
330
|
+
let basicUser: string | undefined;
|
|
329
331
|
if (/^https:\/\//i.test(path) && vendorHosts?.length) {
|
|
330
332
|
// a second host the pack declares for this vendor, on a path its rule names: validated as the root is
|
|
331
333
|
target = new URL(path);
|
|
@@ -335,6 +337,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
335
337
|
&& (r.pathPattern === undefined || new RegExp(r.pathPattern).test(target.pathname));
|
|
336
338
|
const declared = vendorHosts.some((r) => !r.exclude && named(r)) && !vendorHosts.some((r) => r.exclude && named(r));
|
|
337
339
|
if (!declared) throw new Error(`remote execute: ${target.host}${target.pathname.slice(0, 64)} is not a host this vendor's pack declares for this path`);
|
|
340
|
+
basicUser = vendorHosts.find((r) => !r.exclude && r.basic !== undefined && named(r))?.basic;
|
|
338
341
|
// A loopback root is the vendor substitute. Preserve the vendor Host while retaining
|
|
339
342
|
// the already-validated root's mounted path; an absolute URL never escapes that root.
|
|
340
343
|
if (isLoopbackHost(origin.hostname)) {
|
|
@@ -352,6 +355,14 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
352
355
|
// a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
|
|
353
356
|
if (issued !== undefined) outbound.set('authorization', issued);
|
|
354
357
|
else for (const [name, value] of Object.entries(credential.headers)) outbound.set(name, value);
|
|
358
|
+
// A HOST THAT TAKES THE TOKEN AS HTTP BASIC: the sealed authorization header's credentials, after its scheme, are
|
|
359
|
+
// Basic's password under the rule's user; one sealed as Basic goes as it is, and none sealed refuses before egress
|
|
360
|
+
if (issued === undefined && basicUser !== undefined) {
|
|
361
|
+
const sealed = outbound.get('authorization') ?? '';
|
|
362
|
+
const token = /^\S+\s+(\S.*)$/.exec(sealed)?.[1];
|
|
363
|
+
if (token === undefined) throw new Error(`remote execute: ${target.host} takes the credential as HTTP Basic, and the sealed credential carries no authorization header`);
|
|
364
|
+
if (!/^basic\s/i.test(sealed)) outbound.set('authorization', `Basic ${btoa(`${basicUser}:${token}`)}`);
|
|
365
|
+
}
|
|
355
366
|
// the vendor's host, kept over any the credential carries (a loopback twin routes by it, as the World's proxy's)
|
|
356
367
|
if (vendorUrl) { outbound.set('host', vendorUrl.host); outbound.set('x-volter-twin-original-host', vendorUrl.host); }
|
|
357
368
|
|
package/src/file-response.ts
CHANGED
|
@@ -25,10 +25,16 @@ export function fileResponse(path: string, init?: ResponseInit): Response {
|
|
|
25
25
|
* Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
|
|
26
26
|
* 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
|
|
27
27
|
* its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
|
|
28
|
-
* the end (any range of an empty body) is a 416.
|
|
28
|
+
* the end (any range of an empty body) is a 416. Lazy sources supply inclusive ranges and stream in bounded reads.
|
|
29
|
+
* `nosniff` stops a browser second-guessing the content type the vendor gave.
|
|
29
30
|
*/
|
|
30
|
-
export function bytesResponse(
|
|
31
|
-
|
|
31
|
+
export function bytesResponse(
|
|
32
|
+
request: Request,
|
|
33
|
+
bytes: Uint8Array | { size: number; range(start: number, end: number): Promise<Uint8Array | null> },
|
|
34
|
+
contentType: string,
|
|
35
|
+
cacheControl = 'public, max-age=604800, immutable',
|
|
36
|
+
): Response {
|
|
37
|
+
const total = bytes instanceof Uint8Array ? bytes.length : bytes.size;
|
|
32
38
|
const base = { 'content-type': contentType, 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes', 'cache-control': cacheControl };
|
|
33
39
|
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
34
40
|
let start = 0;
|
|
@@ -44,9 +50,25 @@ export function bytesResponse(request: Request, bytes: Uint8Array, contentType:
|
|
|
44
50
|
// every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
|
|
45
51
|
// 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
|
|
46
52
|
const partial = valid;
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
let body: BodyInit | null = null;
|
|
54
|
+
if (request.method !== 'HEAD') {
|
|
55
|
+
if (bytes instanceof Uint8Array) body = new Blob([bytes.slice(start, end + 1)]);
|
|
56
|
+
else {
|
|
57
|
+
let at = start;
|
|
58
|
+
body = new ReadableStream<Uint8Array>({
|
|
59
|
+
async pull(controller) {
|
|
60
|
+
if (at > end) { controller.close(); return; }
|
|
61
|
+
const last = Math.min(at + 8 * 1024 * 1024 - 1, end);
|
|
62
|
+
const chunk = await bytes.range(at, last);
|
|
63
|
+
if (!chunk) { controller.error(new Error('stored bytes are gone')); return; }
|
|
64
|
+
controller.enqueue(chunk);
|
|
65
|
+
at = last + 1;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return new Response(body, {
|
|
49
71
|
status: partial ? 206 : 200,
|
|
50
|
-
headers: { ...base, 'content-length': String(
|
|
72
|
+
headers: { ...base, 'content-length': String(end + 1 - start), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
|
|
51
73
|
});
|
|
52
74
|
}
|
package/src/git/index.ts
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// THE CLIENT HALF OF SMART HTTP (gitprotocol-http, protocol v0): what `git fetch` and `git push` send to a server,
|
|
2
|
+
// over a transport the caller supplies. The library opens no socket, holds no credential and names no host; a root's
|
|
3
|
+
// refresh and deploy reach the vendor's git through it over the kernel executor (derived-real.ts, ADR 0031). It asks
|
|
4
|
+
// only for what it reads: no side-band (the pack arrives bare), no multi_ack (one answer), no thin pack (every delta
|
|
5
|
+
// base is in the pack or the store), ofs-delta (readPack resolves both delta kinds), and on a push report-status, with
|
|
6
|
+
// atomic when the server offers it.
|
|
7
|
+
import { concat, type GitObjectStore } from './objects.ts';
|
|
8
|
+
import { readPack } from './pack.ts';
|
|
9
|
+
import { FLUSH, ZERO_SHA, packObjects, pktLine, readPktLines, type Service } from './smart-http.ts';
|
|
10
|
+
|
|
11
|
+
const DEC = new TextDecoder();
|
|
12
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
13
|
+
|
|
14
|
+
/** One exchange: `GET info/refs?service=<service>` when there is no body, `POST <service>` when there is. It answers
|
|
15
|
+
* the response's bytes, and throws when the server did not answer 200. */
|
|
16
|
+
export type SmartHttpTransport = (request: { service: Service; body?: Uint8Array }) => Promise<Uint8Array>;
|
|
17
|
+
|
|
18
|
+
/** What a server advertises: its refs (peeled `^{}` lines dropped), the ref its HEAD names, and its capabilities. */
|
|
19
|
+
export type RemoteRefs = { refs: Map<string, string>; head: string | null; caps: string[] };
|
|
20
|
+
|
|
21
|
+
/** `GET info/refs?service=<service>`, read. */
|
|
22
|
+
export async function lsRemote(transport: SmartHttpTransport, service: Service): Promise<RemoteRefs> {
|
|
23
|
+
let { lines, rest } = readPktLines(await transport({ service }));
|
|
24
|
+
if (lines[0]?.startsWith('# service=')) ({ lines, rest } = readPktLines(rest));
|
|
25
|
+
const refs = new Map<string, string>(); let head: string | null = null; let caps: string[] = [];
|
|
26
|
+
for (const raw of lines) {
|
|
27
|
+
const [line, offered] = raw.replace(/\n$/, '').split('\0') as [string, string | undefined];
|
|
28
|
+
if (line.startsWith('ERR ')) throw new Error(`git ${service}: ${line.slice(4)}`);
|
|
29
|
+
if (offered !== undefined) {
|
|
30
|
+
caps = offered.split(' ').filter(Boolean);
|
|
31
|
+
const symref = caps.find((c) => c.startsWith('symref=HEAD:'));
|
|
32
|
+
if (symref) head = symref.slice('symref=HEAD:'.length);
|
|
33
|
+
}
|
|
34
|
+
const sha = line.slice(0, 40); const name = line.slice(41);
|
|
35
|
+
if (!SHA.test(sha) || sha === ZERO_SHA || name === 'HEAD' || name.endsWith('^{}')) continue;
|
|
36
|
+
refs.set(name, sha);
|
|
37
|
+
}
|
|
38
|
+
return { refs, head, caps };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isPack = (bytes: Uint8Array, at: number): boolean => DEC.decode(bytes.subarray(at, at + 4)) === 'PACK';
|
|
42
|
+
|
|
43
|
+
/** `POST git-upload-pack` with `done`: every object `wants` reach and `haves` do not, read into `store`. */
|
|
44
|
+
export async function fetchPack(transport: SmartHttpTransport, store: GitObjectStore, wants: string[], haves: string[]): Promise<void> {
|
|
45
|
+
if (wants.length === 0) return;
|
|
46
|
+
const answer = await transport({
|
|
47
|
+
service: 'git-upload-pack',
|
|
48
|
+
body: concat(
|
|
49
|
+
pktLine(`want ${wants[0]} ofs-delta no-progress agent=volter-twin\n`),
|
|
50
|
+
...wants.slice(1).map((w) => pktLine(`want ${w}\n`)),
|
|
51
|
+
FLUSH,
|
|
52
|
+
...haves.map((h) => pktLine(`have ${h}\n`)),
|
|
53
|
+
pktLine('done\n'),
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
// the negotiation's ACK / NAK lines, then the pack
|
|
57
|
+
let at = 0;
|
|
58
|
+
while (at + 4 <= answer.length && !isPack(answer, at)) {
|
|
59
|
+
const len = parseInt(DEC.decode(answer.subarray(at, at + 4)), 16);
|
|
60
|
+
if (Number.isNaN(len)) break;
|
|
61
|
+
if (len < 4) { at += 4; continue; }
|
|
62
|
+
const line = DEC.decode(answer.subarray(at + 4, at + len));
|
|
63
|
+
if (line.startsWith('ERR ')) throw new Error(`git upload-pack: ${line.slice(4).trim()}`);
|
|
64
|
+
at += len;
|
|
65
|
+
}
|
|
66
|
+
if (!isPack(answer, at)) throw new Error('git upload-pack: the server answered no pack');
|
|
67
|
+
await readPack(answer.subarray(at), store);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One ref update a push asks for: `old` is the server's value as advertised (ZERO_SHA to create), `new` the object
|
|
71
|
+
* to point it at (ZERO_SHA to delete). */
|
|
72
|
+
export type PushCommand = { name: string; old: string; new: string };
|
|
73
|
+
|
|
74
|
+
/** `POST git-receive-pack`: the commands and the pack of what they need beyond `haves`, atomic when `atomic` (the
|
|
75
|
+
* server offered it). Answers each command's outcome by ref name: null when the server took it, else its reason. */
|
|
76
|
+
export async function sendPack(transport: SmartHttpTransport, store: GitObjectStore, commands: PushCommand[], haves: string[], atomic = false): Promise<Map<string, string | null>> {
|
|
77
|
+
const updates = commands.filter((c) => c.new !== ZERO_SHA).map((c) => c.new);
|
|
78
|
+
const caps = `report-status${atomic ? ' atomic' : ''} agent=volter-twin`;
|
|
79
|
+
const answer = await transport({
|
|
80
|
+
service: 'git-receive-pack',
|
|
81
|
+
body: concat(
|
|
82
|
+
...commands.map((c, i) => pktLine(`${c.old} ${c.new} ${c.name}${i === 0 ? `\0${caps}` : ''}\n`)),
|
|
83
|
+
FLUSH,
|
|
84
|
+
// a push of deletions alone sends no pack (gitprotocol-pack, "Pushing data to a server")
|
|
85
|
+
...(updates.length ? [await packObjects(store, updates, haves)] : []),
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
const report = readPktLines(answer).lines.map((l) => l.replace(/\n$/, ''));
|
|
89
|
+
const failure = report.find((l) => l.startsWith('ERR '));
|
|
90
|
+
if (failure) throw new Error(`git receive-pack: ${failure.slice(4)}`);
|
|
91
|
+
const unpack = report.find((l) => l.startsWith('unpack '))?.slice('unpack '.length);
|
|
92
|
+
if (unpack === undefined) throw new Error('git receive-pack: the server sent no report');
|
|
93
|
+
const out = new Map<string, string | null>(commands.map((c) => [c.name, unpack === 'ok' ? 'the server reported nothing for it' : `unpacker error: ${unpack}`]));
|
|
94
|
+
for (const l of report) {
|
|
95
|
+
if (l.startsWith('ok ')) out.set(l.slice(3), null);
|
|
96
|
+
else if (l.startsWith('ng ')) { const rest = l.slice(3); const sp = rest.indexOf(' '); out.set(sp === -1 ? rest : rest.slice(0, sp), sp === -1 ? 'refused' : rest.slice(sp + 1)); }
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
package/src/git/smart-http.ts
CHANGED
|
@@ -68,6 +68,13 @@ async function closure(store: GitObjectStore, roots: string[], seen: Set<string>
|
|
|
68
68
|
}
|
|
69
69
|
async function reachable(store: GitObjectStore, roots: string[]): Promise<Set<string>> { const seen = new Set<string>(); await closure(store, roots, seen); return seen; }
|
|
70
70
|
|
|
71
|
+
/** The pack of every object `wants` reach that the `haves` the store holds do not: what a push sends (a client's
|
|
72
|
+
* send-pack, smart-http-client.ts). */
|
|
73
|
+
export async function packObjects(store: GitObjectStore, wants: string[], haves: string[]): Promise<Uint8Array> {
|
|
74
|
+
const known: string[] = []; for (const h of haves) if (await store.has(h)) known.push(h);
|
|
75
|
+
return writePack(await closure(store, wants, await reachable(store, known)));
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
/**
|
|
72
79
|
* A shallow request's commits (gitprotocol-pack, "Shallow requests"): every commit within `depth` of a want (a want is
|
|
73
80
|
* depth 1), and the boundary, the commits whose parents the client will not have. The client's own shallow commits it
|
package/src/head.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type PushOutcome = {
|
|
|
34
34
|
actionId?: string;
|
|
35
35
|
/** the group's other entries the answer named an id for (a resource's `companions`) */
|
|
36
36
|
also?: Array<{ actionId: string; externalId: string; data?: Record<string, unknown> }>;
|
|
37
|
+
/** the group's entries the vendor refused while it took the rest (a git push to a root without `atomic`, each ref its
|
|
38
|
+
* own outcome: ADR 0031); each settles refused with its reason */
|
|
39
|
+
refused?: Array<{ actionId: string; reason: string }>;
|
|
37
40
|
};
|
|
38
41
|
/** What a perform is handed beside the executor: a resolver from a local id to the vendor's (an
|
|
39
42
|
* entry authored against `twin-1` crosses against `REAL-42` once that subject's landing adopted it). */
|
|
@@ -269,6 +272,8 @@ export type DeployReport = {
|
|
|
269
272
|
refused?: { actionId: string; check: string; reason: string };
|
|
270
273
|
/** the first failure, with the vendor's words; nothing after it was attempted */
|
|
271
274
|
failed?: { actionId: string; error: string };
|
|
275
|
+
/** a group the vendor took in part: its entries it refused, each with its reason; nothing after it was attempted */
|
|
276
|
+
partial?: Array<{ actionId: string; reason: string }>;
|
|
272
277
|
/** entries after the refusal or failure, not attempted */
|
|
273
278
|
skipped: string[];
|
|
274
279
|
/** entries whose landed copy already said `deployed` — not performed again */
|
|
@@ -287,12 +292,13 @@ function receiptsByEntry(parent: Entry[]): Map<string, Receipt> {
|
|
|
287
292
|
/** Whether an entry records a call of the vendor's surface (an operation or a GraphQL mutation): what a perform can send
|
|
288
293
|
* again (the real-system adapters, "What the derived core records with a write"). */
|
|
289
294
|
function surfaceWrite(e: { input?: Record<string, unknown> }): boolean {
|
|
290
|
-
|
|
295
|
+
// a push to a World repository is the vendor's too: a deploy sends it as a git push (derived-core.ts pushedInput)
|
|
296
|
+
return typeof e.input?.operationId === 'string' || (e.input?.graphql !== undefined && e.input.graphql !== null) || (e.input?.git !== undefined && e.input.git !== null);
|
|
291
297
|
}
|
|
292
298
|
|
|
293
299
|
/** The branch entries a root twin still has to settle: `set` rows, not reverted, whose landed copy is absent or says
|
|
294
300
|
* `failed`/`skipped` (a `deployed` or `refused` copy settles the entry), split into what is offered to the vendor (a
|
|
295
|
-
* surface write on a subject the vendor can have) and what is the World's own: a write a door, a screen, the gap, the
|
|
301
|
+
* surface write or a git push on a subject the vendor can have) and what is the World's own: a write a door, a screen, the gap, the
|
|
296
302
|
* clock or a seed made, and any write on a subject only the World's own writes made. */
|
|
297
303
|
export function settlingEntries(service: string, root?: string): { offered: TwinAction[]; worldOnly: TwinAction[] } {
|
|
298
304
|
const parent = parentEntries(service, root);
|
|
@@ -436,7 +442,10 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
436
442
|
const answered = outcome.data !== null && typeof outcome.data === 'object' && !Array.isArray(outcome.data) ? (outcome.data as Record<string, unknown>) : {};
|
|
437
443
|
const primary = resolved.find((a) => a.id === outcome.actionId) ?? resolved[0]!;
|
|
438
444
|
const also = new Map((outcome.also ?? []).map((a) => [a.actionId, a]));
|
|
445
|
+
const refusedHere = new Map((outcome.refused ?? []).map((r) => [r.actionId, r.reason]));
|
|
439
446
|
for (const action of resolved) {
|
|
447
|
+
const refusal = refusedHere.get(action.id);
|
|
448
|
+
if (refusal !== undefined) { settle(action, at, { status: 'refused', reason: refusal }); refusedIds.add(action.subject.id); continue; }
|
|
440
449
|
const answeredId = action === primary ? outcome.externalId : also.get(action.id)?.externalId;
|
|
441
450
|
const externalId = answeredId || aliases.get(`${action.subject.type}:${action.subject.id}`);
|
|
442
451
|
settle(action, at, { status: 'deployed', ...(action === primary && outcome.url ? { url: outcome.url } : {}) }, { ...action.fields, ...(action === primary ? answered : also.get(action.id)?.data ?? {}) }, externalId || undefined);
|
|
@@ -444,6 +453,7 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
444
453
|
report.pushed += 1;
|
|
445
454
|
report.deployed.push({ actionId: action.id, at, ...(externalId ? { externalId } : {}), ...(data !== undefined ? { data } : {}), ...(action === primary && outcome.response ? { response: outcome.response } : {}) });
|
|
446
455
|
}
|
|
456
|
+
if (refusedHere.size) { report.partial = [...refusedHere].map(([actionId, reason]) => ({ actionId, reason })); stopped = true; }
|
|
447
457
|
} catch (error) {
|
|
448
458
|
// a write the vendor refuses (a 4xx: it can never be sent as it is) settles the group refused, as a check's
|
|
449
459
|
// refusal does, so the next deploy moves on to the group after it; anything else failed and stays offered
|
|
@@ -515,7 +525,7 @@ export async function deferPerform<T>(request: string, fn: () => Promise<T>): Pr
|
|
|
515
525
|
/** Under `auto`, perform the entries one request made as one call (the real-system adapters, "Perform"): the ids the
|
|
516
526
|
* vendor gave them by local id, or the vendor's refusal or failure, the request's entries reverted as performAtHead
|
|
517
527
|
* reverts one. Nothing when the head is not real and auto, or the request wrote nothing the vendor is offered. */
|
|
518
|
-
export async function performRequestGroup(service: string, request: string, root?: string): Promise<{ ids: Map<string, string>; response?: RemoteExecuteResponse } | RefusedWriteError | VendorWriteError> {
|
|
528
|
+
export async function performRequestGroup(service: string, request: string, root?: string): Promise<{ ids: Map<string, string>; response?: RemoteExecuteResponse; refused?: Array<{ actionId: string; reason: string }> } | RefusedWriteError | VendorWriteError> {
|
|
519
529
|
const head = headOf(service, root);
|
|
520
530
|
const ids = new Map<string, string>();
|
|
521
531
|
if (head.kind !== 'real' || head.deploy !== 'auto') return { ids };
|
|
@@ -525,21 +535,42 @@ export async function performRequestGroup(service: string, request: string, root
|
|
|
525
535
|
try { report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) }); }
|
|
526
536
|
catch (error) {
|
|
527
537
|
if (error instanceof DeploymentOrderError) {
|
|
528
|
-
|
|
538
|
+
revertRequest(service, request, group, root);
|
|
529
539
|
return new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, group[0]!.id);
|
|
530
540
|
}
|
|
531
541
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
532
542
|
}
|
|
533
543
|
if (report.refused || report.failed) {
|
|
534
|
-
|
|
544
|
+
revertRequest(service, request, group, root);
|
|
535
545
|
return report.refused ? new RefusedWriteError(report.refused.check, report.refused.reason, report.refused.actionId) : new VendorWriteError(report.failed!.error, report.failed!.actionId);
|
|
536
546
|
}
|
|
547
|
+
if (report.partial) revertRequest(service, request, group.filter((e) => report.partial!.some((r) => r.actionId === e.id)), root);
|
|
537
548
|
for (const d of report.deployed) {
|
|
538
549
|
const e = group.find((g) => g.id === d.actionId);
|
|
539
550
|
if (e && d.externalId && d.externalId !== e.subject.id) ids.set(e.subject.id, d.externalId);
|
|
540
551
|
}
|
|
541
552
|
const response = report.deployed.find((entry) => entry.response)?.response;
|
|
542
|
-
return { ids, ...(response ? { response } : {}) };
|
|
553
|
+
return { ids, ...(response ? { response } : {}), ...(report.partial ? { refused: report.partial } : {}) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Revert what a request's refused entries made, latest first. An entry that is a declared push a screen recorded
|
|
557
|
+
* (`input.git`, ADR 0031) owns the request's writes after it up to the next such push, its push's effects (the
|
|
558
|
+
* reactions and vendor moves the pack runs on it), which go with it; any other refused entry goes alone, as the group
|
|
559
|
+
* it is in. */
|
|
560
|
+
function revertRequest(service: string, request: string, refused: TwinAction[], root?: string): void {
|
|
561
|
+
const pushed = (e: { input?: Record<string, unknown> }): boolean => e.input?.git !== undefined && e.input.git !== null;
|
|
562
|
+
const undo = new Set<string>();
|
|
563
|
+
if (refused.some(pushed)) {
|
|
564
|
+
const made = (branchEntries(service, root) as TwinAction[]).filter((e) => e.correlationId === request && e.op === 'set');
|
|
565
|
+
for (const r of refused) {
|
|
566
|
+
if (!pushed(r)) { undo.add(r.id); continue; }
|
|
567
|
+
const at = made.findIndex((e) => e.id === r.id);
|
|
568
|
+
if (at < 0) { undo.add(r.id); continue; }
|
|
569
|
+
undo.add(r.id);
|
|
570
|
+
for (const e of made.slice(at + 1)) { if (pushed(e)) break; undo.add(e.id); }
|
|
571
|
+
}
|
|
572
|
+
} else for (const r of refused) undo.add(r.id);
|
|
573
|
+
for (const e of branchEntries(service, root).filter((x) => undo.has(x.id)).reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
543
574
|
}
|
|
544
575
|
|
|
545
576
|
/** Wrap a pack's fetch: a `RefusedWriteError` or `VendorWriteError` the head raised becomes the
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The WHATWG HTML grammar is shared mechanics; what a pack keeps of a document is the pack's own policy.
|
|
2
|
+
// The exact optional dependency belongs to this kernel, so Node and browser bundles resolve one parser.
|
|
3
|
+
// Source: parse5 7.3.0 (parseFragment, serialize, defaultTreeAdapter).
|
|
4
|
+
//
|
|
5
|
+
// A fragment is parsed in an HTML `<body>` context. Elements keep their namespace (HTML, SVG or MathML) and attributes
|
|
6
|
+
// their prefix and namespace, and serialization reproduces what the tree holds: a foreign subtree survives parsing, and
|
|
7
|
+
// a pack's policy may drop it.
|
|
8
|
+
export type HtmlAttribute = { name: string; value: string; prefix?: string; namespace?: string };
|
|
9
|
+
export type HtmlElement = { type: 'element'; name: string; namespace: string; attributes: HtmlAttribute[]; children: HtmlNode[] };
|
|
10
|
+
export type HtmlText = { type: 'text'; value: string };
|
|
11
|
+
export type HtmlNode = HtmlElement | HtmlText;
|
|
12
|
+
export class HtmlError extends Error {
|
|
13
|
+
constructor(readonly code: 'parser-unavailable') {
|
|
14
|
+
super('HTML parser parse5 7.3.0 is unavailable');
|
|
15
|
+
this.name = 'HtmlError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
type Grammar = typeof import('parse5');
|
|
19
|
+
type Tree = import('parse5').DefaultTreeAdapterMap;
|
|
20
|
+
let grammar: Promise<Grammar> | undefined;
|
|
21
|
+
function loadGrammar(): Promise<Grammar> {
|
|
22
|
+
return grammar ??= import('parse5').catch(() => { grammar = undefined; throw new HtmlError('parser-unavailable'); });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fragment parsed as WHATWG fragment parsing does in an HTML `<body>` context: elements and text, comments dropped,
|
|
27
|
+
* an HTML `<template>`'s content carried as that element's children. Importing this module does not load the grammar.
|
|
28
|
+
*/
|
|
29
|
+
export async function parseFragment(source: string): Promise<HtmlNode[]> {
|
|
30
|
+
const { parseFragment: parse, defaultTreeAdapter: tree, html } = await loadGrammar();
|
|
31
|
+
const convert = (nodes: Tree['node'][]): HtmlNode[] => {
|
|
32
|
+
const out: HtmlNode[] = [];
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
if (node.nodeName === '#text') out.push({ type: 'text', value: (node as Tree['textNode']).value });
|
|
35
|
+
else if ('tagName' in node) {
|
|
36
|
+
const element = node as Tree['element'];
|
|
37
|
+
// only an HTML template has content; an SVG or MathML `template` is a foreign element with children
|
|
38
|
+
const children = element.tagName === 'template' && element.namespaceURI === html.NS.HTML
|
|
39
|
+
? (element as Tree['template']).content.childNodes
|
|
40
|
+
: element.childNodes;
|
|
41
|
+
out.push({
|
|
42
|
+
type: 'element', name: element.tagName, namespace: element.namespaceURI,
|
|
43
|
+
attributes: element.attrs.map(attr => ({ name: attr.name, value: attr.value, ...(attr.prefix ? { prefix: attr.prefix } : {}), ...(attr.namespace ? { namespace: attr.namespace } : {}) })),
|
|
44
|
+
children: convert(children),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
return convert(parse(tree.createElement('body', html.NS.HTML, []), source, {}).childNodes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nodes serialized by the WHATWG HTML fragment serialization: its escaping, its void elements, its foreign names. */
|
|
54
|
+
export async function serialize(nodes: HtmlNode[]): Promise<string> {
|
|
55
|
+
const { serialize: write, defaultTreeAdapter: tree, html } = await loadGrammar();
|
|
56
|
+
const append = (parent: Tree['parentNode'], list: HtmlNode[]): void => {
|
|
57
|
+
for (const node of list) {
|
|
58
|
+
if (node.type === 'text') { tree.insertText(parent, node.value); continue; }
|
|
59
|
+
const element = tree.createElement(node.name, node.namespace as import('parse5').html.NS,
|
|
60
|
+
node.attributes.map(({ name, value, prefix, namespace }) => ({ name, value, ...(prefix ? { prefix } : {}), ...(namespace ? { namespace } : {}) })));
|
|
61
|
+
tree.appendChild(parent, element);
|
|
62
|
+
if (node.name === 'template' && node.namespace === html.NS.HTML) {
|
|
63
|
+
const content = tree.createDocumentFragment();
|
|
64
|
+
tree.setTemplateContent(element as Tree['template'], content);
|
|
65
|
+
append(content, node.children);
|
|
66
|
+
} else append(element, node.children);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const fragment = tree.createDocumentFragment();
|
|
70
|
+
append(fragment, nodes);
|
|
71
|
+
return write(fragment);
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,11 +76,13 @@ export { passwordHash, personOf, personWith, recordPerson, shownPersonFields, ty
|
|
|
76
76
|
// the World's managed Postgres a handler reaches as `ctx.engine` (managed-database.ts), and a mail's route (`ctx.mail`)
|
|
77
77
|
export { EngineDatabaseError, EngineUnavailableError, quoteIdent } from './managed-database.ts';
|
|
78
78
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
79
|
-
export type { Mail,
|
|
79
|
+
export type { Mail, MailReply } from './smtp.ts';
|
|
80
80
|
// S3's wire (contract "S3's wire is a kernel library too") and the git plane (contract "The git plane is a kernel library")
|
|
81
81
|
export * as s3 from './s3/wire.ts';
|
|
82
82
|
// Generic strict XML grammar; vendor interpretation remains in its consumer.
|
|
83
83
|
export * as xml from './xml.ts';
|
|
84
|
+
// Generic WHATWG HTML grammar; what a pack keeps of received HTML remains the pack's policy.
|
|
85
|
+
export * as html from './html.ts';
|
|
84
86
|
export * as clickhouse from './clickhouse/index.ts';
|
|
85
87
|
export { sampleFromSchema } from './schema-sample.ts';
|
|
86
88
|
export * as openaiWire from './openai-wire.ts';
|
|
@@ -89,8 +91,10 @@ export * as protobuf from './protobuf.ts';
|
|
|
89
91
|
export * as grpc from './grpc-wire.ts';
|
|
90
92
|
export { acceptH2Connection, type H2Handler, type Http1Handler } from './h2.ts';
|
|
91
93
|
export { placeholderPng } from './placeholder-image.ts';
|
|
94
|
+
export { imageDimensions, readImageHeader, readMp4Header } from './media-header.ts';
|
|
92
95
|
export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
|
|
93
96
|
export * as redis from './redis/index.ts';
|
|
97
|
+
export * as smtpServer from './smtp-server.ts';
|
|
94
98
|
export * as mysql from './mysql/index.ts';
|
|
95
99
|
export * as live from './live/index.ts';
|
|
96
100
|
export * as mongodb from './mongodb/index.ts';
|
|
@@ -102,7 +106,7 @@ export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioE
|
|
|
102
106
|
|
|
103
107
|
export { verifyAttestationBundle } from './attestation.ts';
|
|
104
108
|
|
|
105
|
-
export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
|
|
109
|
+
export { cookieOf, signedIn, browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
|
|
106
110
|
|
|
107
111
|
export { readHostPort } from './host-port.ts';
|
|
108
112
|
export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.ts';
|
package/src/managed-database.ts
CHANGED
|
@@ -254,10 +254,10 @@ export function managedDatabase(url: string, options: { readOnly?: boolean } = {
|
|
|
254
254
|
* walk/journey-kit.ts), its statements sent as its client sends them: a text alone as one simple Query (a migration
|
|
255
255
|
* file, a `BEGIN`), a text with values as one extended-protocol statement, the values bind parameters. A transaction it
|
|
256
256
|
* opens stays open across its statements until it ends it, as on the application's own connection. Each statement's
|
|
257
|
-
* answer is its results in Postgres's text format,
|
|
258
|
-
export type DatabaseSession = { query(text: string, values?: ReadonlyArray<string | null>): Promise<StatementResult[]
|
|
257
|
+
* answer is its results in Postgres's text format, with native column type OIDs retained beside the values. */
|
|
258
|
+
export type DatabaseSession = { query(text: string, values?: ReadonlyArray<string | null>): Promise<Array<StatementResult & { fieldTypes: number[] }>>; end(): Promise<void> };
|
|
259
259
|
|
|
260
|
-
type PgResult = { command?: string | null; rowCount?: number | null; fields?: Array<{ name: string }>; rows?: Array<Array<string | null>> };
|
|
260
|
+
type PgResult = { command?: string | null; rowCount?: number | null; fields?: Array<{ name: string; dataTypeID: number }>; rows?: Array<Array<string | null>> };
|
|
261
261
|
|
|
262
262
|
/** The World's Postgres at `url` as one connection held until `end()`. */
|
|
263
263
|
export async function databaseSession(url: string): Promise<DatabaseSession> {
|
|
@@ -277,6 +277,7 @@ export async function databaseSession(url: string): Promise<DatabaseSession> {
|
|
|
277
277
|
const answer = (await (client.query({ text, ...(values === undefined ? {} : { values }), rowMode: 'array', types: raw }) as Promise<PgResult | PgResult[]>));
|
|
278
278
|
return (Array.isArray(answer) ? answer : [answer]).map((r) => ({
|
|
279
279
|
fields: (r.fields ?? []).map((f) => f.name),
|
|
280
|
+
fieldTypes: (r.fields ?? []).map((f) => f.dataTypeID),
|
|
280
281
|
rows: r.rows ?? [],
|
|
281
282
|
command: [r.command ?? '', r.command === 'INSERT' ? '0' : '', r.rowCount ?? ''].filter((part) => part !== '').join(' '),
|
|
282
283
|
}));
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// File-format headers only: no decoding, vendor limits, processing or rendition policy.
|
|
2
|
+
type ImageType = 'image/png' | 'image/gif' | 'image/jpeg' | 'image/webp';
|
|
3
|
+
type Dimensions = { width: number; height: number };
|
|
4
|
+
type ImageHeader = Dimensions & { jpeg?: { frameEnd: number; nonMarkerBytes: boolean; standaloneMarkers: boolean; fillBytes: boolean; shortSegments: boolean } };
|
|
5
|
+
|
|
6
|
+
const tag = (bytes: Uint8Array, at: number): string => String.fromCharCode(...bytes.subarray(at, at + 4));
|
|
7
|
+
|
|
8
|
+
/** Dimensions in a caller-declared image format, or undefined for an incomplete header. */
|
|
9
|
+
export function imageDimensions(bytes: Uint8Array, type: ImageType): ImageHeader | undefined {
|
|
10
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
11
|
+
if (type === 'image/png') return bytes.length >= 24 ? { width: view.getUint32(16), height: view.getUint32(20) } : undefined;
|
|
12
|
+
if (type === 'image/gif') return bytes.length >= 10 ? { width: view.getUint16(6, true), height: view.getUint16(8, true) } : undefined;
|
|
13
|
+
if (type === 'image/webp') {
|
|
14
|
+
if (bytes.length < 30) return undefined;
|
|
15
|
+
const chunk = tag(bytes, 12);
|
|
16
|
+
if (chunk === 'VP8 ') return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
|
|
17
|
+
if (chunk === 'VP8L') {
|
|
18
|
+
const bits = view.getUint32(21, true);
|
|
19
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) };
|
|
20
|
+
}
|
|
21
|
+
if (chunk === 'VP8X') {
|
|
22
|
+
const u24 = (at: number): number => bytes[at]! | (bytes[at + 1]! << 8) | (bytes[at + 2]! << 16);
|
|
23
|
+
return { width: 1 + u24(24), height: 1 + u24(27) };
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
let at = 2;
|
|
28
|
+
const jpeg = { frameEnd: 0, nonMarkerBytes: false, standaloneMarkers: false, fillBytes: false, shortSegments: false };
|
|
29
|
+
while (at + 4 <= bytes.length) {
|
|
30
|
+
if (bytes[at] !== 0xff) { jpeg.nonMarkerBytes = true; at += 1; continue; }
|
|
31
|
+
const marker = bytes[at + 1]!;
|
|
32
|
+
if (marker === 0xff) { jpeg.fillBytes = true; at += 1; continue; }
|
|
33
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { jpeg.standaloneMarkers = true; at += 2; continue; }
|
|
34
|
+
const length = view.getUint16(at + 2);
|
|
35
|
+
if (length < 2) jpeg.shortSegments = true;
|
|
36
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
37
|
+
return at + 9 <= bytes.length ? { width: view.getUint16(at + 7), height: view.getUint16(at + 5), jpeg: { ...jpeg, frameEnd: at + 9 } } : undefined;
|
|
38
|
+
}
|
|
39
|
+
if (marker === 0xda) return undefined;
|
|
40
|
+
at += 2 + length;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An image's signature and dimensions, without deciding whether a vendor accepts that format or size. */
|
|
46
|
+
export function readImageHeader(bytes: Uint8Array): (ImageHeader & { type: ImageType }) | undefined {
|
|
47
|
+
let type: ImageType;
|
|
48
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) type = 'image/png';
|
|
49
|
+
else if (tag(bytes, 0).startsWith('GIF')) type = 'image/gif';
|
|
50
|
+
else if (bytes[0] === 0xff && bytes[1] === 0xd8) type = 'image/jpeg';
|
|
51
|
+
else if (tag(bytes, 0) === 'RIFF' && tag(bytes, 8) === 'WEBP') type = 'image/webp';
|
|
52
|
+
else return undefined;
|
|
53
|
+
const dimensions = imageDimensions(bytes, type);
|
|
54
|
+
return dimensions ? { type, ...dimensions } : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** ISO BMFF's bounded top-level boxes and movie headers, with raw duration and 16.16 track dimensions. */
|
|
58
|
+
export function readMp4Header(bytes: Uint8Array): {
|
|
59
|
+
fileType: boolean;
|
|
60
|
+
hasMediaData: boolean;
|
|
61
|
+
movies: Array<{ headers: Array<{ timescale: number; duration: number; payloadSize: number }>; tracks: Dimensions[] }>;
|
|
62
|
+
} {
|
|
63
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
64
|
+
function* boxes(from: number, to: number): Generator<{ type: string; start: number; end: number }> {
|
|
65
|
+
let at = from;
|
|
66
|
+
while (at + 8 <= to) {
|
|
67
|
+
let size = view.getUint32(at);
|
|
68
|
+
const type = tag(bytes, at + 4);
|
|
69
|
+
let header = 8;
|
|
70
|
+
if (size === 1) {
|
|
71
|
+
if (at + 16 > to) return;
|
|
72
|
+
size = Number(view.getBigUint64(at + 8));
|
|
73
|
+
header = 16;
|
|
74
|
+
}
|
|
75
|
+
if (size === 0) size = to - at;
|
|
76
|
+
if (!Number.isSafeInteger(size) || size < header || size > to - at) return;
|
|
77
|
+
yield { type, start: at + header, end: at + size };
|
|
78
|
+
at += size;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const result: ReturnType<typeof readMp4Header> = { fileType: bytes.length >= 8 && tag(bytes, 4) === 'ftyp', hasMediaData: false, movies: [] };
|
|
82
|
+
for (const box of boxes(0, bytes.length)) {
|
|
83
|
+
if (box.type === 'mdat' && box.end > box.start) result.hasMediaData = true;
|
|
84
|
+
if (box.type !== 'moov') continue;
|
|
85
|
+
const movie: (typeof result.movies)[number] = { headers: [], tracks: [] };
|
|
86
|
+
result.movies.push(movie);
|
|
87
|
+
for (const child of boxes(box.start, box.end)) {
|
|
88
|
+
const { type, start, end } = child;
|
|
89
|
+
if (type === 'mvhd' && start < end) {
|
|
90
|
+
const version = bytes[start];
|
|
91
|
+
const payloadSize = end - start;
|
|
92
|
+
if (version === 0 && payloadSize >= 20) movie.headers.push({ timescale: view.getUint32(start + 12), duration: view.getUint32(start + 16), payloadSize });
|
|
93
|
+
if (version === 1 && payloadSize >= 32) movie.headers.push({ timescale: view.getUint32(start + 20), duration: Number(view.getBigUint64(start + 24)), payloadSize });
|
|
94
|
+
}
|
|
95
|
+
if (type !== 'trak') continue;
|
|
96
|
+
for (const track of boxes(start, end)) {
|
|
97
|
+
if (track.type !== 'tkhd' || track.start >= track.end) continue;
|
|
98
|
+
const version = bytes[track.start];
|
|
99
|
+
const at = version === 1 ? track.start + 88 : version === 0 ? track.start + 76 : track.end;
|
|
100
|
+
if (at + 8 > track.end) continue;
|
|
101
|
+
const width = view.getUint32(at) / 65536;
|
|
102
|
+
const height = view.getUint32(at + 4) / 65536;
|
|
103
|
+
movie.tracks.push({ width, height });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|