@volter/world-core 3.0.163 → 3.0.164
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/src/browser-session.d.ts +7 -1
- package/dist/src/browser-session.js +21 -10
- package/dist/src/derived-core.d.ts +1 -1
- package/dist/src/derived-core.js +7 -5
- package/dist/src/derived-real.js +15 -3
- package/dist/src/events.d.ts +6 -3
- package/dist/src/events.js +52 -7
- package/dist/src/file-response.d.ts +6 -2
- package/dist/src/file-response.js +29 -5
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -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 +114 -94
- package/dist/src/request-body.d.ts +2 -3
- package/dist/src/request-body.js +2 -3
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +2 -1
- package/dist/src/serve-http.d.ts +1 -1
- package/dist/src/serve-http.js +93 -17
- package/package.json +1 -1
- package/src/browser-session.ts +17 -5
- package/src/derived-core.ts +9 -7
- package/src/derived-real.ts +10 -2
- package/src/events.ts +54 -9
- package/src/file-response.ts +28 -6
- package/src/index.ts +2 -1
- package/src/managed-database.ts +4 -3
- package/src/media-header.ts +108 -0
- package/src/pack-fetch.ts +107 -85
- package/src/request-body.ts +2 -3
- package/src/runtime.ts +2 -1
- package/src/serve-http.ts +70 -16
package/src/derived-core.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue
|
|
|
11
11
|
import { applyTwinWrite, applyTwinWriteAtomic, applyTwinWrites, twinResources, type TwinResource } from './serve.ts';
|
|
12
12
|
import { copyResource, foldLocalWrite, heldSubject, ownFields, parentStamp, subjectHistory, treeChangesSince, treeResources, treeStamp, type Subject } from './log.ts';
|
|
13
13
|
import { performsAtHeadNow } from './head.ts';
|
|
14
|
-
import { getActiveWorldStore } from './world-store.ts';
|
|
14
|
+
import { getActiveWorldStore, type WorldStore } from './world-store.ts';
|
|
15
15
|
import { getActiveBlobStore } from './blob-store.ts';
|
|
16
16
|
import { worldPaths } from './storage.ts';
|
|
17
17
|
import { GitObjectStore } from './git/objects.ts';
|
|
@@ -937,7 +937,7 @@ export type CoreScope = {
|
|
|
937
937
|
root?: string; clock?: () => string; database?: string; readOnly?: boolean;
|
|
938
938
|
/** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
|
|
939
939
|
tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
|
|
940
|
-
/** the application a walk stands in for:
|
|
940
|
+
/** the application a walk stands in for: vendor requests and declared events are answered by it (events.ts) */
|
|
941
941
|
application?: ApplicationStandIn;
|
|
942
942
|
};
|
|
943
943
|
|
|
@@ -1138,9 +1138,9 @@ function mintId(m: DerivedManifest, resource: string, root: string | undefined,
|
|
|
1138
1138
|
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
1139
1139
|
* past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
|
|
1140
1140
|
* rise with time and never repeat. */
|
|
1141
|
-
/** The last snowflake minted per vendor and World
|
|
1141
|
+
/** The last snowflake minted per store, vendor and World: two mints before the first is written (a message
|
|
1142
1142
|
* and its attachment) must not meet. */
|
|
1143
|
-
const lastSnowflake = new Map<string, bigint
|
|
1143
|
+
const lastSnowflake = new WeakMap<WorldStore, Map<string, bigint>>();
|
|
1144
1144
|
function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
|
|
1145
1145
|
const ms = BigInt(Date.parse(at) || 0);
|
|
1146
1146
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
@@ -1149,11 +1149,13 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefin
|
|
|
1149
1149
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
1150
1150
|
for (const r of mintRows(m, name, root, snapshot)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
1151
1151
|
}
|
|
1152
|
+
const store = getActiveWorldStore();
|
|
1153
|
+
const reservations = lastSnowflake.get(store) ?? lastSnowflake.set(store, new Map()).get(store)!;
|
|
1152
1154
|
const key = `${m.service}\0${root ?? ''}`;
|
|
1153
|
-
const last =
|
|
1155
|
+
const last = reservations.get(key) ?? -1n;
|
|
1154
1156
|
const floor = max > last ? max : last;
|
|
1155
1157
|
const id = base > floor ? base : floor + 1n;
|
|
1156
|
-
|
|
1158
|
+
reservations.set(key, id);
|
|
1157
1159
|
return String(id);
|
|
1158
1160
|
}
|
|
1159
1161
|
|
|
@@ -1452,7 +1454,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1452
1454
|
const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
|
|
1453
1455
|
const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
|
|
1454
1456
|
const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
|
|
1455
|
-
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined);
|
|
1457
|
+
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, scopeOfCall.get(call)?.application);
|
|
1456
1458
|
}
|
|
1457
1459
|
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
|
|
1458
1460
|
// a read held until the World writes (held-reads.ts) hears it
|
package/src/derived-real.ts
CHANGED
|
@@ -583,8 +583,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
583
583
|
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
584
584
|
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
585
585
|
// a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
|
|
586
|
-
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
587
586
|
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
587
|
+
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
588
588
|
if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
|
|
589
589
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
590
590
|
read.push(String(subject.id));
|
|
@@ -592,7 +592,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
592
592
|
// the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
|
|
593
593
|
if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
|
|
594
594
|
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
595
|
-
const
|
|
595
|
+
const parsed = parse(answer);
|
|
596
|
+
let full: Row;
|
|
597
|
+
if (op.answers?.list) {
|
|
598
|
+
const items = itemsOf(op, parsed);
|
|
599
|
+
const matching = items.find((item) => (scopeIdOf(scope, item) ?? storedIdOf(decl, item)) === vendorId);
|
|
600
|
+
if (!matching && items.length) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} omitted the requested subject; nothing folded`);
|
|
601
|
+
if (!matching) continue;
|
|
602
|
+
full = matching;
|
|
603
|
+
} else full = object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {};
|
|
596
604
|
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
597
605
|
const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
598
606
|
if (decl.parent) {
|
package/src/events.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
10
11
|
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
|
|
11
12
|
import { equalSecrets as same, ksuidFrom } from './signing.ts';
|
|
12
13
|
import { deliveryTraceHeaders } from './trace-context.ts';
|
|
13
14
|
import { sendRecordedEvent } from './event-delivery.ts';
|
|
14
|
-
import { getActiveWorldStore, withWorldStore } from './world-store.ts';
|
|
15
|
+
import { getActiveWorldStore, withWorldStore, type WorldStore } from './world-store.ts';
|
|
15
16
|
import { worldPaths } from './storage.ts';
|
|
16
17
|
|
|
17
18
|
/** How the vendor signs a delivery. */
|
|
@@ -306,6 +307,26 @@ const worldTransport: EventTransport = async (url, body, headers) => {
|
|
|
306
307
|
try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
|
|
307
308
|
};
|
|
308
309
|
|
|
310
|
+
const pendingDeliveries = new WeakMap<WorldStore, Map<string, Set<Promise<void>>>>();
|
|
311
|
+
|
|
312
|
+
function forgetDeliveries(store: WorldStore, root: string, deliveries: Iterable<Promise<void>>): void {
|
|
313
|
+
const roots = pendingDeliveries.get(store), pending = roots?.get(root);
|
|
314
|
+
if (!pending) return;
|
|
315
|
+
for (const delivery of deliveries) pending.delete(delivery);
|
|
316
|
+
if (!pending.size) roots!.delete(root);
|
|
317
|
+
if (!roots!.size) pendingDeliveries.delete(store);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Await already-scheduled first dispatches and consume their retained persistence failures. */
|
|
321
|
+
export async function settleEventDeliveries(root: string): Promise<void> {
|
|
322
|
+
const store = getActiveWorldStore(), path = resolve(root);
|
|
323
|
+
const deliveries = [...(pendingDeliveries.get(store)?.get(path) ?? [])];
|
|
324
|
+
const outcomes = await Promise.allSettled(deliveries);
|
|
325
|
+
forgetDeliveries(store, path, deliveries);
|
|
326
|
+
const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
|
|
327
|
+
if (failures.length) throw new AggregateError(failures, 'Declared-event dispatch failed');
|
|
328
|
+
}
|
|
329
|
+
|
|
309
330
|
/** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
|
|
310
331
|
* transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
|
|
311
332
|
export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
|
|
@@ -321,13 +342,14 @@ export function setDeliveryObserver(observer: DeliveryObserver | undefined): Del
|
|
|
321
342
|
* (`unreachable`), or it did not answer in time (`timeout`). */
|
|
322
343
|
export type ApplicationAnswer = { status: number; headers: Record<string, string>; body: string; missed?: 'timeout' | 'unreachable' };
|
|
323
344
|
|
|
324
|
-
/** Who answers
|
|
345
|
+
/** Who answers a vendor's requests to the application before the World's application route does, given to a
|
|
325
346
|
* pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
|
|
326
347
|
* application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
|
|
327
348
|
* is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
|
|
328
349
|
* network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
|
|
329
350
|
* answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. `bodyError` models a
|
|
330
|
-
* disconnect after the response headers; a caller needing the complete body receives no complete answer
|
|
351
|
+
* disconnect after the response headers; a caller needing the complete body receives no complete answer, while an
|
|
352
|
+
* event dispatch acknowledges the response status without reading its body. */
|
|
331
353
|
export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; headers?: Record<string, string>; body: string; bodyError?: string } | 'unreachable' | undefined>;
|
|
332
354
|
|
|
333
355
|
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
@@ -367,10 +389,15 @@ export async function deliverEvents(
|
|
|
367
389
|
data: (type: string) => Promise<Record<string, unknown>>,
|
|
368
390
|
transport: EventTransport = worldTransport,
|
|
369
391
|
values?: (type: string) => Promise<Record<string, unknown>>,
|
|
392
|
+
application?: ApplicationStandIn,
|
|
370
393
|
): Promise<EventDelivery[]> {
|
|
371
394
|
if (write.storedType === decl.store) return [];
|
|
395
|
+
const dispatch: EventTransport = application ? async (url, body, headers) => {
|
|
396
|
+
const answer = await application(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
|
|
397
|
+
return answer === undefined ? transport(url, body, headers) : answer === 'unreachable' ? 0 : answer.status;
|
|
398
|
+
} : transport;
|
|
372
399
|
const sent: EventDelivery[] = [];
|
|
373
|
-
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data,
|
|
400
|
+
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type)));
|
|
374
401
|
return sent;
|
|
375
402
|
}
|
|
376
403
|
|
|
@@ -466,10 +493,28 @@ async function deliverEventType(
|
|
|
466
493
|
// active before it runs. Transport completion writes to the same retained state.
|
|
467
494
|
const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
|
|
468
495
|
for (const d of sent) deliveryObserver?.({ ...d, service });
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
496
|
+
const roots = pendingDeliveries.get(store) ?? new Map<string, Set<Promise<void>>>();
|
|
497
|
+
pendingDeliveries.set(store, roots);
|
|
498
|
+
const pending = roots.get(deliveryRoot) ?? new Set<Promise<void>>();
|
|
499
|
+
roots.set(deliveryRoot, pending);
|
|
500
|
+
for (const packet of recorded) {
|
|
501
|
+
let finish!: () => void;
|
|
502
|
+
let fail!: (reason: unknown) => void;
|
|
503
|
+
const dispatched = new Promise<void>((done, reject) => { finish = done; fail = reject; });
|
|
504
|
+
// Live writes return before dispatch; an explicit settlement reports persistence failures.
|
|
505
|
+
void dispatched.catch(() => {});
|
|
506
|
+
pending.add(dispatched);
|
|
507
|
+
setTimeout(async () => {
|
|
508
|
+
try {
|
|
509
|
+
await withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace));
|
|
510
|
+
finish();
|
|
511
|
+
forgetDeliveries(store, deliveryRoot, [dispatched]);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// Keep the rejection until explicit settlement consumes it. The queued/sending claim remains for
|
|
514
|
+
// native recovery; this must not fail the already-committed vendor write or cause a blind resend.
|
|
515
|
+
fail(error);
|
|
516
|
+
}
|
|
517
|
+
}, 0);
|
|
518
|
+
}
|
|
474
519
|
return sent;
|
|
475
520
|
}
|
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/index.ts
CHANGED
|
@@ -89,6 +89,7 @@ export * as protobuf from './protobuf.ts';
|
|
|
89
89
|
export * as grpc from './grpc-wire.ts';
|
|
90
90
|
export { acceptH2Connection, type H2Handler, type Http1Handler } from './h2.ts';
|
|
91
91
|
export { placeholderPng } from './placeholder-image.ts';
|
|
92
|
+
export { imageDimensions, readImageHeader, readMp4Header } from './media-header.ts';
|
|
92
93
|
export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
|
|
93
94
|
export * as redis from './redis/index.ts';
|
|
94
95
|
export * as mysql from './mysql/index.ts';
|
|
@@ -102,7 +103,7 @@ export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioE
|
|
|
102
103
|
|
|
103
104
|
export { verifyAttestationBundle } from './attestation.ts';
|
|
104
105
|
|
|
105
|
-
export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
|
|
106
|
+
export { cookieOf, signedIn, browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
|
|
106
107
|
|
|
107
108
|
export { readHostPort } from './host-port.ts';
|
|
108
109
|
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
|
+
}
|