@volter/world-core 3.0.17 → 3.0.19
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/content-route.d.ts +8 -0
- package/dist/src/content-route.js +27 -0
- package/dist/src/derived-core.d.ts +17 -1
- package/dist/src/derived-core.js +51 -15
- package/dist/src/derived.d.ts +3 -0
- package/dist/src/events.d.ts +13 -2
- package/dist/src/events.js +14 -2
- package/dist/src/file-response.d.ts +4 -3
- package/dist/src/file-response.js +7 -4
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/pack-fetch.d.ts +5 -0
- package/dist/src/pack-fetch.js +99 -13
- package/dist/src/runtime.d.ts +1 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/twin-fetch.d.ts +17 -2
- package/dist/src/twin-fetch.js +53 -4
- package/dist/vendor-hosts.cjs +35 -10
- package/dist/vendor-hosts.d.cts +3 -0
- package/package.json +1 -1
- package/src/content-route.ts +24 -0
- package/src/derived-core.ts +67 -19
- package/src/derived.ts +3 -0
- package/src/events.ts +19 -2
- package/src/file-response.ts +7 -4
- package/src/index.ts +1 -1
- package/src/pack-fetch.ts +104 -15
- package/src/runtime.ts +1 -1
- package/src/twin-fetch.ts +45 -4
- package/vendor-hosts.cjs +35 -10
- package/vendor-hosts.d.cts +3 -0
package/dist/src/twin-fetch.d.ts
CHANGED
|
@@ -8,9 +8,16 @@ export declare const TWIN_PREFIX_HEADER = "x-forwarded-prefix";
|
|
|
8
8
|
* callback) from this, never from the bare origin, so the URL works behind a served World. */
|
|
9
9
|
export declare function twinPublicBase(request: Request): string;
|
|
10
10
|
/** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
|
|
11
|
-
* opens it at, `{host}`
|
|
12
|
-
*
|
|
11
|
+
* opens it at, from the World's sites template. `{host}` stands for the hostname as it is, where the host's names nest
|
|
12
|
+
* (`http://{host}.<world>--<org>.localhost:<port>`); `{site}` for the hostname as one DNS label, its dots as hyphens,
|
|
13
|
+
* where one wildcard certificate covers the host's names (`https://{site}--<world>--<org>.volterdev.com`). A World that
|
|
14
|
+
* routes its sites to a browser sends the template on every request it forwards to a twin. */
|
|
13
15
|
export declare const TWIN_SITES_HEADER = "x-volter-world-sites";
|
|
16
|
+
/** A hostname as one DNS label: its dots as hyphens (`www.volter.ai` is `www-volter-ai`). */
|
|
17
|
+
export declare const siteLabel: (hostname: string) => string;
|
|
18
|
+
/** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
|
|
19
|
+
* (a label over 63 characters). */
|
|
20
|
+
export declare function siteUrlOf(template: string, hostname: string): string | null;
|
|
14
21
|
/** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
|
|
15
22
|
* hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
|
|
16
23
|
* mints its own URLs from twinPublicBase. */
|
|
@@ -79,3 +86,11 @@ export interface TwinFetchAdapterConfig {
|
|
|
79
86
|
responseHeaders?: Record<string, string>;
|
|
80
87
|
}
|
|
81
88
|
export declare function createTwinFetchFromHandler(handler: (request: TwinFetchHandlerRequest) => TwinFetchHandlerResult | Promise<TwinFetchHandlerResult>, config: TwinFetchAdapterConfig): (request: Request) => Promise<Response>;
|
|
89
|
+
/** A twin's answer at a World's place with its cookies kept to the twin: every cookie it sets is scoped to the twin's
|
|
90
|
+
* base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
|
|
91
|
+
* `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
|
|
92
|
+
* so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
|
|
93
|
+
* World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
|
|
94
|
+
* a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
|
|
95
|
+
* is the answer as it is. */
|
|
96
|
+
export declare function placedCookies(request: Request, response: Response): Response;
|
package/dist/src/twin-fetch.js
CHANGED
|
@@ -41,16 +41,35 @@ export function twinPublicBase(request) {
|
|
|
41
41
|
return `${origin}${prefix.replace(/\/+$/, '')}`;
|
|
42
42
|
}
|
|
43
43
|
/** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
|
|
44
|
-
* opens it at, `{host}`
|
|
45
|
-
*
|
|
44
|
+
* opens it at, from the World's sites template. `{host}` stands for the hostname as it is, where the host's names nest
|
|
45
|
+
* (`http://{host}.<world>--<org>.localhost:<port>`); `{site}` for the hostname as one DNS label, its dots as hyphens,
|
|
46
|
+
* where one wildcard certificate covers the host's names (`https://{site}--<world>--<org>.volterdev.com`). A World that
|
|
47
|
+
* routes its sites to a browser sends the template on every request it forwards to a twin. */
|
|
46
48
|
export const TWIN_SITES_HEADER = 'x-volter-world-sites';
|
|
49
|
+
/** A hostname as one DNS label: its dots as hyphens (`www.volter.ai` is `www-volter-ai`). */
|
|
50
|
+
export const siteLabel = (hostname) => hostname.toLowerCase().replace(/\./g, '-');
|
|
51
|
+
/** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
|
|
52
|
+
* (a label over 63 characters). */
|
|
53
|
+
export function siteUrlOf(template, hostname) {
|
|
54
|
+
const host = hostname.toLowerCase();
|
|
55
|
+
if (!/^[a-z0-9.-]+$/.test(host))
|
|
56
|
+
return null;
|
|
57
|
+
// a one-label site is never an IDN's (`xn--…` would be read as punycode, and is no address)
|
|
58
|
+
const filled = /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(template) ? template.replace('{host}', host)
|
|
59
|
+
: /^https?:\/\/\{site\}--[A-Za-z0-9.-]+(:\d+)?$/.test(template) && !host.startsWith('xn--') ? template.replace('{site}', siteLabel(host)) : null;
|
|
60
|
+
try {
|
|
61
|
+
return filled && new URL(filled).hostname.split('.').every((l) => l.length > 0 && l.length <= 63) ? filled : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
47
67
|
/** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
|
|
48
68
|
* hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
|
|
49
69
|
* mints its own URLs from twinPublicBase. */
|
|
50
70
|
export function twinSiteUrl(request, hostname) {
|
|
51
71
|
const at = request.headers.get(TWIN_SITES_HEADER);
|
|
52
|
-
|
|
53
|
-
return at && /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(at) && /^[a-z0-9.-]+$/.test(host) ? at.replace('{host}', host) : `https://${host}`;
|
|
72
|
+
return (at ? siteUrlOf(at, hostname) : null) ?? `https://${hostname.toLowerCase()}`;
|
|
54
73
|
}
|
|
55
74
|
/** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
|
|
56
75
|
* `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
|
|
@@ -159,3 +178,33 @@ async function readScoped(run) {
|
|
|
159
178
|
return out.value;
|
|
160
179
|
return { status: 405, body: { error: 'read_only', message: out.refused.message } };
|
|
161
180
|
}
|
|
181
|
+
/** A twin's answer at a World's place with its cookies kept to the twin: every cookie it sets is scoped to the twin's
|
|
182
|
+
* base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
|
|
183
|
+
* `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
|
|
184
|
+
* so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
|
|
185
|
+
* World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
|
|
186
|
+
* a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
|
|
187
|
+
* is the answer as it is. */
|
|
188
|
+
export function placedCookies(request, response) {
|
|
189
|
+
const prefix = request.headers.get(TWIN_PREFIX_HEADER)?.replace(/\/+$/, '');
|
|
190
|
+
if (!prefix || !/^\/[A-Za-z0-9._~\/-]*$/.test(prefix))
|
|
191
|
+
return response;
|
|
192
|
+
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
193
|
+
if (!cookies.length || response.status < 200)
|
|
194
|
+
return response;
|
|
195
|
+
const scoped = cookies.map((cookie) => {
|
|
196
|
+
if (/^\s*__Host-/i.test(cookie))
|
|
197
|
+
return cookie;
|
|
198
|
+
const path = /;\s*path=([^;]*)/i.exec(cookie)?.[1]?.trim();
|
|
199
|
+
// already scoped (a lane's answer its vendor passes on): as it is
|
|
200
|
+
if (path !== undefined && (path === prefix || path.startsWith(`${prefix}/`)))
|
|
201
|
+
return cookie;
|
|
202
|
+
const at = !path || path === '/' ? prefix : `${prefix}${path.startsWith('/') ? path : `/${path}`}`.replace(/\/+$/, '');
|
|
203
|
+
return path === undefined ? `${cookie}; Path=${at}` : cookie.replace(/(;\s*)path=[^;]*/i, `$1Path=${at}`);
|
|
204
|
+
});
|
|
205
|
+
const headers = new Headers(response.headers);
|
|
206
|
+
headers.delete('set-cookie');
|
|
207
|
+
for (const cookie of scoped)
|
|
208
|
+
headers.append('set-cookie', cookie);
|
|
209
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
210
|
+
}
|
package/dist/vendor-hosts.cjs
CHANGED
|
@@ -55,6 +55,39 @@ const COMPILED = new Map();
|
|
|
55
55
|
/** The hand table's keys, before any pack's rules: a pack installed later replaces its hand entry in place (keeping its
|
|
56
56
|
* position, which decides who answers a shared host first), where the built-in facts may not declare one twice. */
|
|
57
57
|
const HAND = new Set(Object.keys(VENDOR_HOSTS));
|
|
58
|
+
/** One host rule as a predicate over (hostname, pathname?): its one selector (host | suffix | hostPattern) and its
|
|
59
|
+
* pathPattern, a path left out matching any. */
|
|
60
|
+
function compileHostRule(rule, where) {
|
|
61
|
+
const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
|
|
62
|
+
if (selectors !== 1) throw new Error(`inject: ${where} rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
|
|
63
|
+
const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
|
|
64
|
+
const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
|
|
65
|
+
return (h, p) =>
|
|
66
|
+
(rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
|
|
67
|
+
&& (pathRe === null || p === undefined || pathRe.test(p));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A pack's host rules (its descriptor's `hosts`) read as the injector reads them: `names(host)`, whether any include
|
|
71
|
+
* rule's selector names the host, whatever the path; `takes(host, path)`, whether the request is the pack's (under
|
|
72
|
+
* one of its keys, an include matches and no exclude does). The content route at a World's place holds a
|
|
73
|
+
* `<base>/@<host>/<path>` to it (pack-fetch.ts). */
|
|
74
|
+
function hostRules(rules) {
|
|
75
|
+
const byKey = new Map();
|
|
76
|
+
for (const rule of rules || []) {
|
|
77
|
+
const key = rule.key === undefined ? '' : rule.key;
|
|
78
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
79
|
+
byKey.get(key).push(rule);
|
|
80
|
+
}
|
|
81
|
+
const keys = [...byKey.values()].map((keyRules) => ({
|
|
82
|
+
includes: keyRules.filter((r) => r.exclude !== true).map((r) => compileHostRule(r, 'a pack')),
|
|
83
|
+
excludes: keyRules.filter((r) => r.exclude === true).map((r) => compileHostRule(r, 'a pack')),
|
|
84
|
+
}));
|
|
85
|
+
return {
|
|
86
|
+
names: (h) => keys.some((k) => k.includes.some((m) => m(h))),
|
|
87
|
+
takes: (h, p) => keys.some((k) => k.includes.some((m) => m(h, p)) && !k.excludes.some((m) => m(h, p))),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
function compilePackHosts(packs, overlay) {
|
|
59
92
|
for (const vendor of Object.keys(packs)) {
|
|
60
93
|
const rules = packs[vendor].hosts;
|
|
@@ -77,15 +110,7 @@ function compilePackHosts(packs, overlay) {
|
|
|
77
110
|
if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
|
|
78
111
|
throw new Error(`inject: key "${key}" (pack ${vendor}) is declared twice — on this descriptor and in the hand VENDOR_HOSTS table or another pack's descriptor; a key has one home.`);
|
|
79
112
|
}
|
|
80
|
-
const compile = (rule) => {
|
|
81
|
-
const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
|
|
82
|
-
if (selectors !== 1) throw new Error(`inject: key "${key}" (pack ${vendor}) rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
|
|
83
|
-
const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
|
|
84
|
-
const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
|
|
85
|
-
return (h, p) =>
|
|
86
|
-
(rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
|
|
87
|
-
&& (pathRe === null || p === undefined || pathRe.test(p));
|
|
88
|
-
};
|
|
113
|
+
const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
|
|
89
114
|
const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
|
|
90
115
|
const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
|
|
91
116
|
VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
|
|
@@ -141,4 +166,4 @@ function twinOrigins(env) {
|
|
|
141
166
|
return map;
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
module.exports = { VENDOR_HOSTS, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
|
|
169
|
+
module.exports = { VENDOR_HOSTS, hostRules, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
|
package/dist/vendor-hosts.d.cts
CHANGED
|
@@ -6,3 +6,6 @@ export function vendorsOfHost(hostname: string): string[];
|
|
|
6
6
|
export function twinEnvStem(vendor: string): string;
|
|
7
7
|
/** The vendors whose twin `env` points at, each with its origin. */
|
|
8
8
|
export function twinOrigins(env: Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
9
|
+
/** A pack's host rules (its descriptor's `hosts`, as data) read as the injector reads them: whether any include names a
|
|
10
|
+
* host whatever the path, and whether a request to a host and path is the pack's. */
|
|
11
|
+
export function hostRules(rules: ReadonlyArray<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true }> | undefined): { names(hostname: string): boolean; takes(hostname: string, pathname: string): boolean };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.19",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// THE CONTENT ROUTE'S ADDRESS (architecture, "Screens", "A page at a World's place"): at a World's place, what a page
|
|
2
|
+
// reaches on another of the pack's hosts that a content screen serves is `<base>/@<host>/<path>`. This reads that
|
|
3
|
+
// address as a browser would have written it: a doubled slash collapsed, `%40` for `@`, the host's case, a trailing dot
|
|
4
|
+
// and a default port (80, 443) normalised away. What it reads is a candidate only: the route is taken when the host is
|
|
5
|
+
// one a built content screen names (pack-fetch.ts), and a request naming no such host is general routing's, as before.
|
|
6
|
+
|
|
7
|
+
/** A `/@<host>/<path>` address: the host normalised, the path under it (slashes collapsed), and why the spelling cannot
|
|
8
|
+
* be the host's content (`refused`: a port other than the default), when it cannot. */
|
|
9
|
+
export type ContentAddress = { host: string; path: string; refused?: string };
|
|
10
|
+
|
|
11
|
+
export function contentAddressOf(pathname: string): ContentAddress | undefined {
|
|
12
|
+
const collapsed = pathname.replace(/\/{2,}/g, '/');
|
|
13
|
+
const named = /^\/(?:@|%40)([^/]+)(\/.*)?$/i.exec(collapsed);
|
|
14
|
+
if (!named) return undefined;
|
|
15
|
+
let token: string;
|
|
16
|
+
try { token = decodeURIComponent(named[1]!).toLowerCase(); } catch { return undefined; }
|
|
17
|
+
const at = /^([^:]*)(?::(\d*))?$/.exec(token);
|
|
18
|
+
if (!at) return undefined;
|
|
19
|
+
const host = at[1]!.replace(/\.$/, '');
|
|
20
|
+
if (!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/.test(host)) return undefined;
|
|
21
|
+
const port = at[2];
|
|
22
|
+
const refused = port && port !== '80' && port !== '443' ? `port ${port} is not the host's` : undefined;
|
|
23
|
+
return { host, path: named[2] ?? '/', ...(refused ? { refused } : {}) };
|
|
24
|
+
}
|
package/src/derived-core.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { worldNow } from './world-clock.ts';
|
|
|
16
16
|
import { worldEnvValue } from './world-env.ts';
|
|
17
17
|
import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
|
|
18
18
|
import { MACHINE_POOL, poolFor, type MachinePool } from './machines.ts';
|
|
19
|
-
import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
|
|
19
|
+
import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type ApplicationStandIn, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
|
|
20
20
|
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
21
21
|
import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
22
22
|
import { hashFieldValue } from './hash.ts';
|
|
@@ -214,7 +214,9 @@ export type LaneRoute = {
|
|
|
214
214
|
export type LanesDecl = { cors?: CorsDecl; routes: ReadonlyArray<LaneRoute>; default?: string };
|
|
215
215
|
/** The manifest of a vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services): no API of its own,
|
|
216
216
|
* only what the vendor's front says — its discovery facts, its lanes and its descriptor. */
|
|
217
|
-
export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration
|
|
217
|
+
export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration;
|
|
218
|
+
/** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
|
|
219
|
+
vendorBacked?: DerivedManifest['vendorBacked'] };
|
|
218
220
|
|
|
219
221
|
/** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
|
|
220
222
|
export type ErrorSpec = { status: number; message: string; code?: string | number; param?: string; kind?: string };
|
|
@@ -235,6 +237,15 @@ export type ScreenDecl = {
|
|
|
235
237
|
demand: string;
|
|
236
238
|
status: 'done' | 'todo';
|
|
237
239
|
controls?: string[];
|
|
240
|
+
/** wherever this screen would answer (its host, a World's place, its path alone), a request the pack's API names for
|
|
241
|
+
* its method is the operation's, not this screen's: a site whose pages draw while its forms and links act on
|
|
242
|
+
* operations the spec writes at the same paths (Hacker News's /login page posts to POST /login; its front page at `/`
|
|
243
|
+
* would otherwise take GET /vote) (architecture, "Screens") */
|
|
244
|
+
yieldsToApi?: true;
|
|
245
|
+
/** a content screen that takes a form at a World's place (`<base>/@<host>/…`), beside GET and HEAD: the methods it
|
|
246
|
+
* takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
|
|
247
|
+
* at a World's place") */
|
|
248
|
+
takes?: ReadonlyArray<'POST'>;
|
|
238
249
|
/** the vendor's documentation of the round trip or the screen */
|
|
239
250
|
source: string;
|
|
240
251
|
};
|
|
@@ -524,6 +535,14 @@ export function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unknown): bo
|
|
|
524
535
|
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
525
536
|
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
526
537
|
* (`metadata[order]=007`, `name=2024`). */
|
|
538
|
+
/** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
|
|
539
|
+
* may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
|
|
540
|
+
export function ownField(node: Record<string, unknown> | unknown[], key: string, value: unknown): void {
|
|
541
|
+
Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
|
|
542
|
+
}
|
|
543
|
+
/** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
|
|
544
|
+
const ownValue = (node: Record<string, unknown> | unknown[], key: string): unknown => (Object.hasOwn(node, key) ? (node as Record<string, unknown>)[key] : undefined);
|
|
545
|
+
|
|
527
546
|
export function parseBracketForm(text: string, scalars?: Readonly<Record<string, string | undefined>>): Record<string, unknown> {
|
|
528
547
|
const out: Record<string, unknown> = {};
|
|
529
548
|
for (const [rawKey, raw] of new URLSearchParams(text)) {
|
|
@@ -533,18 +552,26 @@ export function parseBracketForm(text: string, scalars?: Readonly<Record<string,
|
|
|
533
552
|
: kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
|
|
534
553
|
: kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
|
|
535
554
|
: raw;
|
|
536
|
-
|
|
537
|
-
|
|
555
|
+
// every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
|
|
556
|
+
// `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
|
|
557
|
+
let node: Record<string, unknown> | unknown[] = out;
|
|
558
|
+
for (const [i, key] of parts.entries()) {
|
|
559
|
+
// an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
|
|
560
|
+
// go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
|
|
561
|
+
if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key)) break;
|
|
538
562
|
if (i === parts.length - 1) {
|
|
539
563
|
if (key === '') {
|
|
540
564
|
if (Array.isArray(node)) node.push(value);
|
|
541
|
-
} else node
|
|
542
|
-
|
|
565
|
+
} else ownField(node, key, value);
|
|
566
|
+
break;
|
|
543
567
|
}
|
|
544
568
|
const next = parts[i + 1]!;
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
569
|
+
if (key === '') break;
|
|
570
|
+
let child = ownValue(node, key);
|
|
571
|
+
if (child === undefined) { child = next === '' || /^\d+$/.test(next) ? [] : {}; ownField(node, key, child); }
|
|
572
|
+
if (child === null || typeof child !== 'object') break;
|
|
573
|
+
node = child as Record<string, unknown> | unknown[];
|
|
574
|
+
}
|
|
548
575
|
}
|
|
549
576
|
return out;
|
|
550
577
|
}
|
|
@@ -580,26 +607,38 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
|
|
|
580
607
|
const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
|
|
581
608
|
const out: Record<string, unknown> = { ...query };
|
|
582
609
|
for (const part of parts) {
|
|
610
|
+
// each part a field of its own name, whatever the name (`__proto__` included: ownField)
|
|
583
611
|
if (part.filename === null) {
|
|
584
|
-
out
|
|
612
|
+
ownField(out, part.name, new TextDecoder().decode(part.body));
|
|
585
613
|
continue;
|
|
586
614
|
}
|
|
587
615
|
const bytes = part.body;
|
|
588
616
|
const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
|
|
589
|
-
out
|
|
617
|
+
ownField(out, part.name, Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false }));
|
|
590
618
|
}
|
|
591
619
|
return out;
|
|
592
620
|
}
|
|
593
|
-
const text = await bodyTextOf(request);
|
|
594
|
-
if (!text) return query;
|
|
595
621
|
// NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
|
|
596
|
-
const
|
|
622
|
+
const media = (type.split(';')[0] ?? '').trim().toLowerCase();
|
|
623
|
+
const isJson = media.includes('json') && !media.includes('ndjson');
|
|
624
|
+
// a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
|
|
625
|
+
// octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
|
|
626
|
+
// query's, and the handler reads its text or bytes itself
|
|
627
|
+
const isForm = media === 'application/x-www-form-urlencoded';
|
|
597
628
|
const asJson = isJson || manifest.body.json === 'always';
|
|
629
|
+
if (!isForm && !asJson) return query;
|
|
630
|
+
const text = await bodyTextOf(request);
|
|
631
|
+
if (!text) return query;
|
|
598
632
|
// `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
|
|
599
|
-
// as
|
|
633
|
+
// as a form only when it is labelled one (a sign-in page's own form, posted to the vendor's page), never a crash
|
|
600
634
|
let body: unknown;
|
|
601
|
-
if (asJson) {
|
|
602
|
-
|
|
635
|
+
if (asJson) {
|
|
636
|
+
try { body = JSON.parse(text); } catch {
|
|
637
|
+
if (isJson) throw new SyntaxError('malformed JSON body');
|
|
638
|
+
if (!isForm) return query;
|
|
639
|
+
body = parseBracketForm(text, scalars);
|
|
640
|
+
}
|
|
641
|
+
} else body = parseBracketForm(text, scalars);
|
|
603
642
|
// a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
|
|
604
643
|
return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...(body as Record<string, unknown>) } : { ...query };
|
|
605
644
|
}
|
|
@@ -743,6 +782,8 @@ export type CoreScope = {
|
|
|
743
782
|
root?: string; clock?: () => string; database?: string; readOnly?: boolean;
|
|
744
783
|
/** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
|
|
745
784
|
tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
|
|
785
|
+
/** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
|
|
786
|
+
application?: ApplicationStandIn;
|
|
746
787
|
};
|
|
747
788
|
|
|
748
789
|
function now(m: DerivedManifest, at: string): unknown {
|
|
@@ -979,6 +1020,9 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
|
|
|
979
1020
|
return { operationId: id, ...asSent };
|
|
980
1021
|
}
|
|
981
1022
|
|
|
1023
|
+
/** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
|
|
1024
|
+
const scopeOfCall = new WeakMap<DerivedCall, CoreScope>();
|
|
1025
|
+
|
|
982
1026
|
async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
983
1027
|
const withFiles = async (input: Record<string, unknown>): Promise<Record<string, unknown>> => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
|
|
984
1028
|
const { resource: row, result } = await applyTwinWrite(
|
|
@@ -993,7 +1037,9 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
|
|
|
993
1037
|
);
|
|
994
1038
|
await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
|
|
995
1039
|
const body = view(m, resource, row);
|
|
996
|
-
|
|
1040
|
+
// the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
|
|
1041
|
+
// the managed database), at the write's moment
|
|
1042
|
+
const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(scopeOfCall.get(call) ?? {}), ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
|
|
997
1043
|
if (m.onWrite) await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
|
|
998
1044
|
if (m.events) {
|
|
999
1045
|
const events = m.events;
|
|
@@ -1314,6 +1360,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
1314
1360
|
const decl = resource ? m.resources[resource] : undefined;
|
|
1315
1361
|
if (!resource || !decl) return { unmodeled: `no manifest entry for resource ${resource ?? '(none)'}` };
|
|
1316
1362
|
call = adoptedPath(m, call, root);
|
|
1363
|
+
scopeOfCall.set(call, scope);
|
|
1317
1364
|
// the body is read from a copy, so the call's own request is still unread for the write hook's context
|
|
1318
1365
|
const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
|
|
1319
1366
|
const idParam = subjectOf(m, resource, call);
|
|
@@ -1626,6 +1673,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1626
1673
|
const root = scope.root;
|
|
1627
1674
|
const asAsked = call;
|
|
1628
1675
|
call = adoptedPath(m, call, root);
|
|
1676
|
+
scopeOfCall.set(call, scope);
|
|
1629
1677
|
const at = (scope.clock ?? worldNow)();
|
|
1630
1678
|
// the guard's parse when it made one (taken once), else this context's own
|
|
1631
1679
|
const parsed = takeParsedBody(call.request);
|
|
@@ -1658,7 +1706,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
1658
1706
|
publicBase: twinPublicBase(call.request),
|
|
1659
1707
|
scenario: scenarioDecisionOf(call.request),
|
|
1660
1708
|
deliver: (url, init) => deliverToApplication(url, init),
|
|
1661
|
-
ask: (url, init, within) => askApplication(url, init, within),
|
|
1709
|
+
ask: (url, init, within) => askApplication(url, init, within, scope.application),
|
|
1662
1710
|
worldEnv: (name) => worldEnvValue(name),
|
|
1663
1711
|
asVendor: (fn) => runAsVendorMove(fn),
|
|
1664
1712
|
git: (name) => {
|
package/src/derived.ts
CHANGED
|
@@ -202,6 +202,9 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
|
|
|
202
202
|
boards?: boolean;
|
|
203
203
|
/** Whether a built screen of it takes a URL: a board frame it draws. */
|
|
204
204
|
draws?(url: string): boolean;
|
|
205
|
+
/** The hosts its built content screens name: a vendor's lane router sends `<base>/@<host>/<path>` for one of them to
|
|
206
|
+
* this fetch (pack-fetch.ts). */
|
|
207
|
+
contentHosts?: ReadonlyArray<string>;
|
|
205
208
|
};
|
|
206
209
|
|
|
207
210
|
/** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
|
package/src/events.ts
CHANGED
|
@@ -323,9 +323,26 @@ export async function deliverToApplication(url: string, init: RequestInit): Prom
|
|
|
323
323
|
* World let no request out to it or it could not be reached (`unreachable`), or it did not answer in time (`timeout`). */
|
|
324
324
|
export type ApplicationAnswer = { status: number; body: string; missed?: 'timeout' | 'unreachable' };
|
|
325
325
|
|
|
326
|
+
/** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
|
|
327
|
+
* pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
|
|
328
|
+
* application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
|
|
329
|
+
* is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
|
|
330
|
+
* network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
|
|
331
|
+
* answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. */
|
|
332
|
+
export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; body: string } | 'unreachable' | undefined>;
|
|
333
|
+
|
|
326
334
|
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
327
|
-
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds
|
|
328
|
-
|
|
335
|
+
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
|
|
336
|
+
* fetch's stand-in first when it was given one (its window the same). */
|
|
337
|
+
export async function askApplication(url: string, init: RequestInit, within: number, standIn?: ApplicationStandIn): Promise<ApplicationAnswer> {
|
|
338
|
+
if (standIn) {
|
|
339
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
+
const late = new Promise<'late'>((done) => { timer = setTimeout(() => done('late'), within); });
|
|
341
|
+
const stood = await Promise.race([standIn(url, init), late]).finally(() => clearTimeout(timer));
|
|
342
|
+
if (stood === 'late') return { status: 0, body: '', missed: 'timeout' };
|
|
343
|
+
if (stood === 'unreachable') return { status: 0, body: '', missed: 'unreachable' };
|
|
344
|
+
if (stood !== undefined) return stood;
|
|
345
|
+
}
|
|
329
346
|
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
330
347
|
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; text(): Promise<string> }> }>,
|
|
331
348
|
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
|
package/src/file-response.ts
CHANGED
|
@@ -22,9 +22,10 @@ export function fileResponse(path: string, init?: ResponseInit): Response {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
|
|
26
|
+
* 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
|
|
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. `nosniff` stops a browser second-guessing the content type the vendor gave.
|
|
28
29
|
*/
|
|
29
30
|
export function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl = 'public, max-age=604800, immutable'): Response {
|
|
30
31
|
const total = bytes.length;
|
|
@@ -40,7 +41,9 @@ export function bytesResponse(request: Request, bytes: Uint8Array, contentType:
|
|
|
40
41
|
}
|
|
41
42
|
if (start >= total || (range[1] === '' && Number(range[2]) === 0)) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
// every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
|
|
45
|
+
// 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
|
|
46
|
+
const partial = valid;
|
|
44
47
|
const body = bytes.slice(start, end + 1);
|
|
45
48
|
return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
|
|
46
49
|
status: partial ? 206 : 200,
|
package/src/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts'
|
|
|
52
52
|
export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
|
|
53
53
|
export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
|
|
54
54
|
// where a page links a hostname its twin answers, inside the World that shows it
|
|
55
|
-
export { twinSiteUrl } from './twin-fetch.ts';
|
|
55
|
+
export { siteLabel, siteUrlOf, twinSiteUrl } from './twin-fetch.ts';
|
|
56
56
|
export { statefulTwinManifest, twinManifest } from './scenario.ts';
|
|
57
57
|
export { assetContentType, packAsset } from './pack-assets.ts';
|
|
58
58
|
export { bytesResponse, contentTypeOf, fileResponse } from './file-response.ts';
|