@zixcel/view-delivery 0.10.2 → 0.10.5
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/README.md +6 -0
- package/package.json +23 -9
- package/src/failure.d.mts +2 -0
- package/src/failure.mjs +32 -0
- package/src/handler.d.mts +8 -0
- package/src/handler.mjs +121 -0
- package/src/index.d.mts +3 -51
- package/src/index.mjs +143 -224
- package/src/prepare.d.mts +24 -0
- package/src/prepare.mjs +31 -0
- package/src/types.d.mts +101 -0
- package/src/types.mjs +1 -0
- package/src/wire.d.mts +18 -0
- package/src/wire.mjs +100 -0
package/README.md
CHANGED
|
@@ -40,3 +40,9 @@ This workspace owns only the host and delivery contracts.
|
|
|
40
40
|
## Release provenance
|
|
41
41
|
|
|
42
42
|
Starting with 0.10.1, releases use GitHub Actions OIDC trusted publishing from `zixcel/view-delivery` (`publish-npm.yml`), without a stored npm publishing token. The workflow verifies reviewed archive hashes and requires npm provenance metadata. Check the published version on npm and verify registry signatures and provenance with `npm audit signatures` in an installed consumer project. Package runtime behavior and API contracts are unchanged by this release.
|
|
43
|
+
|
|
44
|
+
## Node.js security baseline
|
|
45
|
+
|
|
46
|
+
Use a supported Node.js security release for Node-based build, delivery and consumer processes: at least 22.23.4, 24.22.1 or 26.12.1. The renderer runs in the browser and does not bundle Node. Updating an npm library does not update the caller's Node executable. The October 21, 2026 security fixes include TLS, DNS, HTTP and Brotli handling; operator-managed runtimes must be updated independently. GitHub Actions pins select the shell/build runtime, not the GitHub-managed runtime of JavaScript Actions.
|
|
47
|
+
|
|
48
|
+
Official advisory: https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zixcel/view-delivery",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
|
|
7
|
+
},
|
|
5
8
|
"license": "Apache-2.0",
|
|
6
9
|
"description": "Bounded trusted document and asset delivery; existing Crowsi STATE transport only",
|
|
7
10
|
"files": [
|
|
@@ -18,15 +21,12 @@
|
|
|
18
21
|
}
|
|
19
22
|
},
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"@zixcel/view-delivery-contracts": "0.10.2",
|
|
22
|
-
"@crowsi/transport-foundation": "0.10.0",
|
|
23
24
|
"@crowsi/browser-security": "0.10.0",
|
|
24
|
-
"
|
|
25
|
+
"@crowsi/transport-foundation": "0.10.0",
|
|
25
26
|
"@types/node": "24.12.0",
|
|
26
|
-
"@zixcel/projection-contracts": "0.10.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"test": "node --test test/*.test.mjs"
|
|
27
|
+
"@zixcel/projection-contracts": "0.10.4",
|
|
28
|
+
"@zixcel/view-delivery-contracts": "0.10.4",
|
|
29
|
+
"ws": "8.22.0"
|
|
30
30
|
},
|
|
31
31
|
"types": "./src/index.d.mts",
|
|
32
32
|
"repository": {
|
|
@@ -37,5 +37,19 @@
|
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public",
|
|
39
39
|
"registry": "https://registry.npmjs.org"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"data-presentation",
|
|
43
|
+
"artifact-distribution",
|
|
44
|
+
"api-server",
|
|
45
|
+
"typescript"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"typescript": "7.0.2"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test --test-concurrency=1 test/*.test.mts",
|
|
52
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit"
|
|
40
54
|
}
|
|
41
|
-
}
|
|
55
|
+
}
|
package/src/failure.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { requirement } from '@zixcel/view-delivery-contracts';
|
|
2
|
+
import { record } from './wire.mjs';
|
|
3
|
+
const accessCodes = new Set(['host-rejected', 'origin-rejected', 'origin-required', 'fetch-site-rejected', 'session-capacity-reached', 'session-rejected', 'session-expired', 'csrf-rejected',
|
|
4
|
+
'request-nonce-invalid', 'request-replayed', 'request-nonce-capacity-reached'].map(c => 'crowsi-browser-security-' + c));
|
|
5
|
+
const deliveryCodes = new Set(['SessionRejected', 'InvalidInput', 'DeliveryLimitExceeded', 'DeliveryCancelled', 'DeliveryTimedOut', 'InvalidDeliveryData',
|
|
6
|
+
'InvalidAsset', 'NotFound', 'MethodNotAllowed', 'ProjectionCancelled', 'ProjectionRuntimeClosed', 'ProjectionQueueFull',
|
|
7
|
+
'ProjectionStoreBusy', 'ProjectionStoreCorrupt', 'SourceUnavailable', 'SourceNotRetained', 'SourceRevisionMismatch',
|
|
8
|
+
'ProjectionUnavailable', 'ProjectionLimitExceeded', 'ProjectionExecutionTimeout', 'StaleSceneAction', 'SceneUnavailable',
|
|
9
|
+
'InteractionUnavailable', 'InvalidProjection', 'OriginRequired', 'SocketUnavailable']);
|
|
10
|
+
function classifyFailure(value, operation, observation) {
|
|
11
|
+
const error = record(value);
|
|
12
|
+
const message = error.message, errorCode = error.code, typed = record(error.failure), typedCode = typed.code;
|
|
13
|
+
const access = typeof message === 'string' && accessCodes.has(message);
|
|
14
|
+
let original = null;
|
|
15
|
+
try {
|
|
16
|
+
original = requirement({ ref: typed.requirementRef, owner: typed.owner, state: 'Unavailable', failure: typed, setup: null }).failure;
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
const code = original?.code ?? (access ? String(message) : typeof errorCode === 'string' && deliveryCodes.has(errorCode) ? errorCode :
|
|
20
|
+
typeof typedCode === 'string' && /^[a-z][a-z0-9-]{2,95}$/.test(typedCode) ? typedCode : 'UnhandledDeliveryFault');
|
|
21
|
+
return { code, canonical: { state: observation.receiptRef ? 'Committed' : ['DELIVERY_REJECTED', 'NOT_DISPATCHED'].includes(observation.stage) ? 'NotDispatched' : observation.stage === 'OWNER_REJECTED' ? 'Rejected' : 'Unknown', receiptRef: observation.receiptRef }, ownerFailure: { owner: original?.owner ?? (access ? 'crowsi/browser-security' : observation.owner), operation: original?.operation ?? observation.operation ?? operation,
|
|
22
|
+
code, requirementRef: original?.requirementRef ?? 'delivery:server', outcomeStage: observation.stage, uncertain: observation.stage === 'OUTCOME_UNCERTAIN',
|
|
23
|
+
requestRef: observation.requestRef, managementRequestRef: observation.managementRequestRef ?? null, receiptRef: observation.receiptRef, detail: original?.detail ?? null } };
|
|
24
|
+
}
|
|
25
|
+
export function sourceFailure(value, operation, observation) {
|
|
26
|
+
try {
|
|
27
|
+
return classifyFailure(value, operation, observation);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return classifyFailure(undefined, operation, observation);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { DeliveryServerOptions, Observation } from './types.mjs';
|
|
3
|
+
import type { Prepared } from './prepare.mjs';
|
|
4
|
+
export declare function createHandler(options: DeliveryServerOptions, prepared: Prepared): {
|
|
5
|
+
authorize: (request: IncomingMessage) => void;
|
|
6
|
+
authenticate: (request: IncomingMessage) => string;
|
|
7
|
+
handle: (request: IncomingMessage, response: ServerResponse, signal: AbortSignal, observation: Observation) => Promise<void>;
|
|
8
|
+
};
|
package/src/handler.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { assessRequestBoundary } from '@crowsi/browser-security/boundary';
|
|
3
|
+
import { bounds, validateDelivery } from '@zixcel/view-delivery-contracts';
|
|
4
|
+
import { body, headers, header, record, sessionId, safeDocument, send, fail } from './wire.mjs';
|
|
5
|
+
export function createHandler(options, prepared) {
|
|
6
|
+
const { origin, site, readinessToken } = options, { ownerPolicy, boundary, authority, assetMap } = prepared;
|
|
7
|
+
const authorize = (request) => {
|
|
8
|
+
const reason = assessRequestBoundary(boundary, { method: request.method ?? '', host: header(request, 'host'), origin: header(request, 'origin') || undefined, fetchSite: header(request, 'sec-fetch-site') || undefined });
|
|
9
|
+
if (reason)
|
|
10
|
+
fail(reason);
|
|
11
|
+
};
|
|
12
|
+
const authenticate = (request) => { const id = sessionId(request, ownerPolicy.cookieName); authority.authenticate(id); return id; };
|
|
13
|
+
const connection = (request, response) => {
|
|
14
|
+
let current;
|
|
15
|
+
try {
|
|
16
|
+
const id = authenticate(request);
|
|
17
|
+
current = { sessionId: id, ...authority.authenticate(id) };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
current = authority.issue();
|
|
21
|
+
}
|
|
22
|
+
response.setHeader('set-cookie', `${ownerPolicy.cookieName}=${current.sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800${boundary.protocol === 'https:' ? '; Secure' : ''}`);
|
|
23
|
+
return { csrf: current.csrfToken, expiresAtUnixMs: current.expiresAt };
|
|
24
|
+
};
|
|
25
|
+
async function handle(request, response, signal, observation) {
|
|
26
|
+
for (const [key, value] of Object.entries(headers))
|
|
27
|
+
response.setHeader(key, value);
|
|
28
|
+
authorize(request);
|
|
29
|
+
if (typeof request.url !== 'string' || request.url.length > 4096)
|
|
30
|
+
fail('InvalidInput');
|
|
31
|
+
const url = new URL(request.url, origin), pathname = url.pathname;
|
|
32
|
+
if (request.method === 'GET' && pathname === '/api/ready') {
|
|
33
|
+
const provided = Buffer.from(String(request.headers[ownerPolicy.readinessHeader] ?? '')), expected = Buffer.from(readinessToken ?? '');
|
|
34
|
+
if (!expected.length || provided.length !== expected.length || !timingSafeEqual(provided, expected))
|
|
35
|
+
return send(response, 404, { error: { code: 'NotFound' } });
|
|
36
|
+
observation.stage = 'NOT_DISPATCHED';
|
|
37
|
+
response.writeHead(204);
|
|
38
|
+
response.end();
|
|
39
|
+
return; // liveness only, never ProductReady
|
|
40
|
+
}
|
|
41
|
+
if (request.method === 'GET' && assetMap.has(pathname)) {
|
|
42
|
+
observation.stage = 'NOT_DISPATCHED';
|
|
43
|
+
const asset = assetMap.get(pathname);
|
|
44
|
+
response.writeHead(200, { 'content-type': asset.descriptor.kind === 'script' ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8', 'content-length': asset.content.length });
|
|
45
|
+
response.end(asset.content);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (request.method === 'POST' && pathname === '/api/connection') {
|
|
49
|
+
const value = await body(request, signal);
|
|
50
|
+
if (!value || Array.isArray(value) || Object.keys(value).length)
|
|
51
|
+
fail('InvalidInput');
|
|
52
|
+
observation.stage = 'NOT_DISPATCHED';
|
|
53
|
+
send(response, 200, connection(request, response));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (request.method === 'GET' && !pathname.startsWith('/api/')) {
|
|
57
|
+
observation.stage = 'NOT_DISPATCHED';
|
|
58
|
+
const csrf = connection(request, response);
|
|
59
|
+
if (!site.document)
|
|
60
|
+
fail('SourceUnavailable');
|
|
61
|
+
const { envelope, bootstrap } = await site.document(url, { signal, assets: [...assetMap.values()].map(a => a.descriptor) });
|
|
62
|
+
validateDelivery(envelope, ownerPolicy.contract);
|
|
63
|
+
if (JSON.stringify(envelope.assets) !== JSON.stringify([...assetMap.values()].map(a => a.descriptor)))
|
|
64
|
+
fail('InvalidAsset');
|
|
65
|
+
const document = safeDocument(envelope, { ...bootstrap, ...csrf }, ownerPolicy.title);
|
|
66
|
+
if (document.length > bounds.documentBytes)
|
|
67
|
+
fail('DeliveryLimitExceeded');
|
|
68
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': document.length });
|
|
69
|
+
response.end(document);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
authenticate(request);
|
|
73
|
+
if (request.method === 'GET') {
|
|
74
|
+
if (!site.read)
|
|
75
|
+
fail('SourceUnavailable');
|
|
76
|
+
observation.stage = 'NOT_DISPATCHED';
|
|
77
|
+
send(response, 200, await site.read(url, { signal }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (request.method !== 'POST')
|
|
81
|
+
return send(response, 405, { error: { code: 'MethodNotAllowed' } });
|
|
82
|
+
authority.authorizeMutation(sessionId(request, ownerPolicy.cookieName), header(request, ownerPolicy.csrfHeader), header(request, ownerPolicy.nonceHeader));
|
|
83
|
+
const value = await body(request, signal);
|
|
84
|
+
observation.stage = 'OUTCOME_UNCERTAIN';
|
|
85
|
+
// The site was entered, not proof of physical dispatch or canonical commit.
|
|
86
|
+
// Only exact owner evidence may refine this transient observation.
|
|
87
|
+
const observe = (input) => {
|
|
88
|
+
const value = record(input);
|
|
89
|
+
// Trusted site supplies evidence only. Ignore malformed observations; never
|
|
90
|
+
// change execution branching, mint identity, or persist a competing receipt.
|
|
91
|
+
if (typeof value.stage !== 'string' || !['NOT_DISPATCHED', 'OWNER_REJECTED', 'OUTCOME_UNCERTAIN', 'OWNER_COMMITTED', 'PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage))
|
|
92
|
+
return;
|
|
93
|
+
if (typeof value.owner !== 'string' || !ownerPolicy.allowedOwners.includes(value.owner))
|
|
94
|
+
return;
|
|
95
|
+
if (typeof value.operation !== 'string' || !/^[a-z][a-z0-9]*(?:\/[a-z][a-zA-Z0-9]*)+$/.test(value.operation))
|
|
96
|
+
return;
|
|
97
|
+
if (value.receiptRef !== undefined && value.receiptRef !== null && (typeof value.receiptRef !== 'string' || !/^[a-f0-9]{64}$/.test(value.receiptRef)))
|
|
98
|
+
return;
|
|
99
|
+
if (['OWNER_COMMITTED', 'PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage) && !value.receiptRef)
|
|
100
|
+
return;
|
|
101
|
+
if (observation.receiptRef && value.receiptRef !== observation.receiptRef)
|
|
102
|
+
return;
|
|
103
|
+
if (value.managementRequestRef !== undefined && value.managementRequestRef !== null && (typeof value.managementRequestRef !== 'number' || !Number.isSafeInteger(value.managementRequestRef) || value.managementRequestRef < 1))
|
|
104
|
+
return;
|
|
105
|
+
Object.assign(observation, { stage: value.stage, owner: value.owner, operation: value.operation, receiptRef: value.receiptRef ?? null });
|
|
106
|
+
if (value.managementRequestRef !== undefined)
|
|
107
|
+
observation.managementRequestRef = value.managementRequestRef;
|
|
108
|
+
};
|
|
109
|
+
if (!site.command)
|
|
110
|
+
fail('SourceUnavailable');
|
|
111
|
+
const result = await site.command(url, value, { signal, observe, requestRef: observation.requestRef });
|
|
112
|
+
const receipt = record(record(result).canonical).receiptRef;
|
|
113
|
+
if (typeof receipt === 'string' && /^[a-f0-9]{64}$/.test(receipt)) {
|
|
114
|
+
observation.receiptRef = receipt;
|
|
115
|
+
observation.stage = record(record(result).projection).state === 'FailedTyped' ? 'PUBLICATION_FAILED_AFTER_COMMIT' : 'OWNER_COMMITTED';
|
|
116
|
+
}
|
|
117
|
+
if (!response.writableEnded && !response.destroyed)
|
|
118
|
+
send(response, 200, result);
|
|
119
|
+
}
|
|
120
|
+
return { authorize, authenticate, handle };
|
|
121
|
+
}
|
package/src/index.d.mts
CHANGED
|
@@ -1,51 +1,3 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export interface RequestContext {readonly signal:AbortSignal}
|
|
6
|
-
export interface DocumentContext extends RequestContext {readonly assets:readonly Asset[]}
|
|
7
|
-
export interface CommandContext extends RequestContext {
|
|
8
|
-
readonly requestRef:string
|
|
9
|
-
observe(value:unknown):void
|
|
10
|
-
}
|
|
11
|
-
export interface DeliverySite {
|
|
12
|
-
document(url:URL,context:DocumentContext):Promise<{envelope:Delivery;bootstrap:Readonly<Record<string,Json>>}>|{envelope:Delivery;bootstrap:Readonly<Record<string,Json>>}
|
|
13
|
-
read(url:URL,context:RequestContext):unknown|Promise<unknown>
|
|
14
|
-
command(url:URL,value:unknown,context:CommandContext):unknown|Promise<unknown>
|
|
15
|
-
live(request:unknown):unknown|Promise<unknown>
|
|
16
|
-
close?():void|Promise<void>
|
|
17
|
-
}
|
|
18
|
-
export interface TrustedAsset {
|
|
19
|
-
readonly path:string
|
|
20
|
-
readonly bytes:Uint8Array
|
|
21
|
-
readonly bytesLength:number
|
|
22
|
-
readonly digest:string
|
|
23
|
-
readonly kind:'script'|'style'
|
|
24
|
-
}
|
|
25
|
-
export interface DeliveryServer {
|
|
26
|
-
readonly server:Server
|
|
27
|
-
notify():void
|
|
28
|
-
inspect():{readonly alive:boolean;readonly activeRequests:number;readonly transport:unknown}
|
|
29
|
-
listen():Promise<void>
|
|
30
|
-
close():Promise<void>
|
|
31
|
-
}
|
|
32
|
-
export interface DeliveryPolicy {
|
|
33
|
-
readonly owner:string
|
|
34
|
-
readonly title:string
|
|
35
|
-
readonly contract:string
|
|
36
|
-
readonly allowedOwners:readonly string[]
|
|
37
|
-
readonly cookieName?:string
|
|
38
|
-
readonly csrfHeader?:string
|
|
39
|
-
readonly nonceHeader?:string
|
|
40
|
-
readonly readinessHeader?:string
|
|
41
|
-
}
|
|
42
|
-
export interface DeliveryServerOptions {
|
|
43
|
-
readonly policy:DeliveryPolicy
|
|
44
|
-
readonly origin:string
|
|
45
|
-
readonly site:DeliverySite
|
|
46
|
-
readonly assets:readonly TrustedAsset[]
|
|
47
|
-
readonly readinessToken?:string|null
|
|
48
|
-
readonly onFailure?:((failure:unknown)=>void)|null
|
|
49
|
-
readonly onOutcome?:((outcome:unknown)=>void)|null
|
|
50
|
-
}
|
|
51
|
-
export function createDeliveryServer(options:DeliveryServerOptions):DeliveryServer
|
|
1
|
+
import type { DeliveryServerOptions, DeliveryServer } from './types.mjs';
|
|
2
|
+
export type * from './types.mjs';
|
|
3
|
+
export declare function createDeliveryServer(options: DeliveryServerOptions): DeliveryServer;
|
package/src/index.mjs
CHANGED
|
@@ -1,225 +1,144 @@
|
|
|
1
|
-
// Caller-owned
|
|
2
|
-
import http from 'node:http'
|
|
3
|
-
import {
|
|
4
|
-
import {WebSocketServer} from 'ws'
|
|
5
|
-
import {StateSocketServer} from '@crowsi/transport-foundation/state-socket'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if(typeof value.owner!=='string'||!ownerPolicy.allowedOwners.includes(value.owner))return
|
|
145
|
-
if(typeof value.operation!=='string'||!/^[a-z][a-z0-9]*(?:\/[a-z][a-zA-Z0-9]*)+$/.test(value.operation))return
|
|
146
|
-
if(value.receiptRef!==undefined&&value.receiptRef!==null&&!/^[a-f0-9]{64}$/.test(value.receiptRef))return
|
|
147
|
-
if(['OWNER_COMMITTED','PUBLICATION_FAILED_AFTER_COMMIT'].includes(value.stage)&&!value.receiptRef)return
|
|
148
|
-
if(observation.receiptRef&&value.receiptRef!==observation.receiptRef)return
|
|
149
|
-
if(value.managementRequestRef!==undefined&&value.managementRequestRef!==null&&(!Number.isSafeInteger(value.managementRequestRef)||value.managementRequestRef<1))return
|
|
150
|
-
Object.assign(observation,{stage:value.stage,owner:value.owner,operation:value.operation,receiptRef:value.receiptRef??null})
|
|
151
|
-
if(value.managementRequestRef!==undefined)observation.managementRequestRef=value.managementRequestRef
|
|
152
|
-
}
|
|
153
|
-
const result=await site.command(url,value,{signal,observe,requestRef:observation.requestRef})
|
|
154
|
-
const receipt=result?.canonical?.receiptRef
|
|
155
|
-
if(typeof receipt==='string'&&/^[a-f0-9]{64}$/.test(receipt)){
|
|
156
|
-
observation.receiptRef=receipt;observation.stage=result.projection?.state==='FailedTyped'?'PUBLICATION_FAILED_AFTER_COMMIT':'OWNER_COMMITTED'
|
|
157
|
-
}
|
|
158
|
-
if(!response.writableEnded&&!response.destroyed)send(response,200,result)
|
|
159
|
-
}
|
|
160
|
-
const server=http.createServer({maxHeaderSize:16384,headersTimeout:5000,requestTimeout:5000,keepAliveTimeout:2000},(request,response)=>{
|
|
161
|
-
if(closing){response.writeHead(503);response.end();return}
|
|
162
|
-
let release;try{release=budget.reserve(0)}catch{send(response,503,{error:{code:'DeliveryQueueFull'}});return}
|
|
163
|
-
const controller=new AbortController();controllers.add(controller)
|
|
164
|
-
const abort=()=>controller.abort();response.once('close',abort)
|
|
165
|
-
const observation={requestRef:randomUUID(),owner:ownerPolicy.owner,stage:'DELIVERY_REJECTED',receiptRef:null}
|
|
166
|
-
// Paths and query strings are caller input, not safe diagnostic labels.
|
|
167
|
-
const path=request.url?.split('?')[0],operation=['/api/subjects','/api/interactions','/api/site-actions','/api/setup','/api/connection','/api/document'].includes(path)?path:'delivery/request'
|
|
168
|
-
const recordFailure=error=>{
|
|
169
|
-
const failure=sourceFailure(error,operation,observation)
|
|
170
|
-
try{onFailure?.(failure)}catch{}
|
|
171
|
-
return failure
|
|
172
|
-
}
|
|
173
|
-
// Preserve the finite HTTP observer deadline, but send a truthful outcome
|
|
174
|
-
// rather than resetting a reused socket (browsers may retransmit that POST).
|
|
175
|
-
// This does not cancel an accepted owner action or renew its execution budget.
|
|
176
|
-
const timer=setTimeout(()=>{
|
|
177
|
-
if(!response.writableEnded&&!response.destroyed){
|
|
178
|
-
const failure=recordFailure(Object.assign(Error('DeliveryTimedOut'),{code:'DeliveryTimedOut'}))
|
|
179
|
-
send(response,504,{error:failure})
|
|
180
|
-
}
|
|
181
|
-
controller.abort()
|
|
182
|
-
},bounds.bodyMs)
|
|
183
|
-
const work=handle(request,response,controller.signal,observation).catch(error=>{
|
|
184
|
-
const failure=recordFailure(error)
|
|
185
|
-
// Read-only diagnostic callback: no inherited preload, payload, credentials or
|
|
186
|
-
// stack. A diagnostic consumer cannot alter the execution branch by throwing.
|
|
187
|
-
if(!response.headersSent&&!response.destroyed)send(response,400,{error:failure})
|
|
188
|
-
else if(!response.writableEnded&&!response.destroyed)response.destroy()
|
|
189
|
-
}).finally(()=>{
|
|
190
|
-
try{onOutcome?.({...observation,operation:observation.operation??operation,httpStatus:response.headersSent?response.statusCode:null})}catch{}
|
|
191
|
-
clearTimeout(timer);response.off('close',abort);release();controllers.delete(controller);inflight.delete(work)
|
|
192
|
-
});inflight.add(work)
|
|
193
|
-
})
|
|
194
|
-
server.maxConnections=32;server.maxRequestsPerSocket=128
|
|
195
|
-
server.on('upgrade',(request,socket,head)=>{
|
|
196
|
-
try{if(closing||request.url!=='/api/projection-live'||hub.inspect().connections>=8)fail('SocketUnavailable');authorize(request);authenticate(request);if(request.headers.origin!==origin)fail('OriginRequired')}
|
|
197
|
-
catch{socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return}
|
|
198
|
-
sockets.handleUpgrade(request,socket,head,websocket=>{
|
|
199
|
-
const peer={request,websocket,close:(code,reason)=>websocket.close(code,reason)}
|
|
200
|
-
websocket.on('message',data=>hub.message(peer,data));websocket.on('close',()=>hub.disconnect(peer));websocket.on('error',()=>hub.disconnect(peer));hub.open(peer)
|
|
201
|
-
})
|
|
202
|
-
})
|
|
203
|
-
return {server,notify:()=>hub.notify(),inspect:()=>({alive:server.listening,activeRequests:inflight.size,transport:hub.inspect()}),
|
|
204
|
-
async listen(){await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(Number(boundary.port),'127.0.0.1',resolve)})},
|
|
205
|
-
close(){
|
|
206
|
-
if(closePromise)return closePromise
|
|
207
|
-
closing=true
|
|
208
|
-
// Closing delivery revokes intake and releases transport, not physical work.
|
|
209
|
-
// Let the owning site close its resources BEFORE awaiting its pending calls:
|
|
210
|
-
// their completion may depend on that close. Join one lifecycle promise even
|
|
211
|
-
// for concurrent close callers, and preserve an owner cleanup failure.
|
|
212
|
-
closePromise=Promise.resolve().then(async()=>{
|
|
213
|
-
for(const c of controllers)c.abort()
|
|
214
|
-
hub.close();for(const ws of sockets.clients)ws.terminate()
|
|
215
|
-
const transportClosed=new Promise(resolve=>sockets.close(resolve))
|
|
216
|
-
const httpClosed=new Promise(resolve=>server.close(resolve))
|
|
217
|
-
server.closeIdleConnections();server.closeAllConnections()
|
|
218
|
-
const ownerClosed=Promise.resolve().then(()=>site.close?.())
|
|
219
|
-
const results=await Promise.allSettled([transportClosed,httpClosed,ownerClosed,...inflight])
|
|
220
|
-
const failures=results.filter(result=>result.status==='rejected').map(result=>result.reason)
|
|
221
|
-
if(failures.length)throw new AggregateError(failures,'Delivery shutdown failed')
|
|
222
|
-
})
|
|
223
|
-
return closePromise
|
|
224
|
-
}}
|
|
1
|
+
// Caller-owned delivery and lifecycle. Crowsi retains transport/session/ACK/recovery.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
5
|
+
import { StateSocketServer } from '@crowsi/transport-foundation/state-socket';
|
|
6
|
+
import { bounds } from '@zixcel/view-delivery-contracts';
|
|
7
|
+
import { prepareDelivery } from './prepare.mjs';
|
|
8
|
+
import { createHandler } from './handler.mjs';
|
|
9
|
+
import { send, fail, record } from './wire.mjs';
|
|
10
|
+
import { sourceFailure } from './failure.mjs';
|
|
11
|
+
export function createDeliveryServer(options) {
|
|
12
|
+
const { site, origin, onFailure, onOutcome } = options;
|
|
13
|
+
const prepared = prepareDelivery(options), { ownerPolicy, boundary, budget } = prepared;
|
|
14
|
+
const { handle, authorize, authenticate } = createHandler(options, prepared);
|
|
15
|
+
const inflight = new Set(), controllers = new Set(), requests = new WeakMap();
|
|
16
|
+
let closing = false, closePromise = null;
|
|
17
|
+
const hub = new StateSocketServer({ authorize: peer => { const request = requests.get(peer); if (!request)
|
|
18
|
+
fail('SessionRejected'); authorize(request); if (request.headers.origin !== origin)
|
|
19
|
+
fail('OriginRequired'); return authenticate(request); }, read: async (request) => {
|
|
20
|
+
if (!site.live)
|
|
21
|
+
fail('SourceUnavailable');
|
|
22
|
+
const value = record(await site.live(request));
|
|
23
|
+
if (value.kind === 'RecoveryUnavailable')
|
|
24
|
+
return { kind: 'RecoveryUnavailable', detail: value.detail };
|
|
25
|
+
if (value.kind === 'NoChange' && typeof value.revision === 'string')
|
|
26
|
+
return { kind: 'NoChange', revision: value.revision };
|
|
27
|
+
if (value.kind === 'Snapshot' && typeof value.revision === 'string' && value.payload instanceof Uint8Array)
|
|
28
|
+
return { kind: 'Snapshot', revision: value.revision, payload: value.payload };
|
|
29
|
+
return fail('InvalidDeliveryData');
|
|
30
|
+
} });
|
|
31
|
+
const sockets = new WebSocketServer({ noServer: true, maxPayload: 1048578, perMessageDeflate: false, clientTracking: true });
|
|
32
|
+
const server = http.createServer({ maxHeaderSize: 16384, headersTimeout: 5000, requestTimeout: 5000, keepAliveTimeout: 2000 }, (request, response) => {
|
|
33
|
+
if (closing) {
|
|
34
|
+
response.writeHead(503);
|
|
35
|
+
response.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let release;
|
|
39
|
+
try {
|
|
40
|
+
release = budget.reserve(0);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
send(response, 503, { error: { code: 'DeliveryQueueFull' } });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
controllers.add(controller);
|
|
48
|
+
const abort = () => controller.abort();
|
|
49
|
+
response.once('close', abort);
|
|
50
|
+
const observation = { requestRef: randomUUID(), owner: ownerPolicy.owner, stage: 'DELIVERY_REJECTED', receiptRef: null };
|
|
51
|
+
// Paths and query strings are caller input, not safe diagnostic labels.
|
|
52
|
+
const path = request.url?.split('?')[0], operation = ['/api/subjects', '/api/interactions', '/api/site-actions', '/api/setup', '/api/connection', '/api/document'].includes(path ?? '') ? path : 'delivery/request';
|
|
53
|
+
const recordFailure = (error) => {
|
|
54
|
+
const failure = sourceFailure(error, operation, observation);
|
|
55
|
+
try {
|
|
56
|
+
Promise.resolve(onFailure?.(failure)).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
return failure;
|
|
60
|
+
};
|
|
61
|
+
// Preserve the finite HTTP observer deadline, but send a truthful outcome
|
|
62
|
+
// rather than resetting a reused socket (browsers may retransmit that POST).
|
|
63
|
+
// This does not cancel an accepted owner action or renew its execution budget.
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
if (!response.writableEnded && !response.destroyed) {
|
|
66
|
+
const failure = recordFailure(Object.assign(Error('DeliveryTimedOut'), { code: 'DeliveryTimedOut' }));
|
|
67
|
+
send(response, 504, { error: failure });
|
|
68
|
+
}
|
|
69
|
+
controller.abort();
|
|
70
|
+
}, bounds.bodyMs);
|
|
71
|
+
const work = handle(request, response, controller.signal, observation).catch(error => {
|
|
72
|
+
const failure = recordFailure(error);
|
|
73
|
+
// Read-only diagnostic callback: no inherited preload, payload, credentials or
|
|
74
|
+
// stack. A diagnostic consumer cannot alter the execution branch by throwing.
|
|
75
|
+
if (!response.headersSent && !response.destroyed)
|
|
76
|
+
send(response, 400, { error: failure });
|
|
77
|
+
else if (!response.writableEnded && !response.destroyed)
|
|
78
|
+
response.destroy();
|
|
79
|
+
}).finally(() => {
|
|
80
|
+
try {
|
|
81
|
+
Promise.resolve(onOutcome?.({ ...observation, operation: observation.operation ?? operation, httpStatus: response.headersSent ? response.statusCode : null })).catch(() => { });
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
response.off('close', abort);
|
|
86
|
+
release();
|
|
87
|
+
controllers.delete(controller);
|
|
88
|
+
inflight.delete(work);
|
|
89
|
+
});
|
|
90
|
+
inflight.add(work);
|
|
91
|
+
});
|
|
92
|
+
server.maxConnections = 32;
|
|
93
|
+
server.maxRequestsPerSocket = 128;
|
|
94
|
+
server.on('upgrade', (request, socket, head) => {
|
|
95
|
+
try {
|
|
96
|
+
if (closing || request.url !== '/api/projection-live' || hub.inspect().connections >= 8)
|
|
97
|
+
fail('SocketUnavailable');
|
|
98
|
+
authorize(request);
|
|
99
|
+
authenticate(request);
|
|
100
|
+
if (request.headers.origin !== origin)
|
|
101
|
+
fail('OriginRequired');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
sockets.handleUpgrade(request, socket, head, websocket => {
|
|
108
|
+
const peer = { request: { headers: new Headers() }, websocket, close: (code, reason) => websocket.close(code, reason) };
|
|
109
|
+
requests.set(peer, request);
|
|
110
|
+
websocket.on('message', data => hub.message(peer, Array.isArray(data) ? Buffer.concat(data) : data));
|
|
111
|
+
websocket.on('close', () => hub.disconnect(peer));
|
|
112
|
+
websocket.on('error', () => hub.disconnect(peer));
|
|
113
|
+
hub.open(peer);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return { server, notify: () => hub.notify(), inspect: () => ({ alive: server.listening, activeRequests: inflight.size, transport: hub.inspect() }),
|
|
117
|
+
async listen() { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(boundary.port), '127.0.0.1', resolve); }); },
|
|
118
|
+
close() {
|
|
119
|
+
if (closePromise)
|
|
120
|
+
return closePromise;
|
|
121
|
+
closing = true;
|
|
122
|
+
// Closing delivery revokes intake and releases transport, not physical work.
|
|
123
|
+
// Let the owning site close its resources BEFORE awaiting its pending calls:
|
|
124
|
+
// their completion may depend on that close. Join one lifecycle promise even
|
|
125
|
+
// for concurrent close callers, and preserve an owner cleanup failure.
|
|
126
|
+
closePromise = Promise.resolve().then(async () => {
|
|
127
|
+
for (const c of controllers)
|
|
128
|
+
c.abort();
|
|
129
|
+
hub.close();
|
|
130
|
+
for (const ws of sockets.clients)
|
|
131
|
+
ws.terminate();
|
|
132
|
+
const transportClosed = new Promise(resolve => sockets.close(() => resolve()));
|
|
133
|
+
const httpClosed = new Promise(resolve => server.close(() => resolve()));
|
|
134
|
+
server.closeIdleConnections();
|
|
135
|
+
server.closeAllConnections();
|
|
136
|
+
const ownerClosed = Promise.resolve().then(() => site.close?.());
|
|
137
|
+
const results = await Promise.allSettled([transportClosed, httpClosed, ownerClosed, ...inflight]);
|
|
138
|
+
const failures = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
139
|
+
if (failures.length)
|
|
140
|
+
throw new AggregateError(failures, 'Delivery shutdown failed');
|
|
141
|
+
});
|
|
142
|
+
return closePromise;
|
|
143
|
+
} };
|
|
225
144
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Budget } from '@crowsi/transport-foundation';
|
|
2
|
+
import { SessionAuthority } from '@crowsi/browser-security/access';
|
|
3
|
+
import type { Asset } from '@zixcel/view-delivery-contracts';
|
|
4
|
+
import type { DeliveryServerOptions } from './types.mjs';
|
|
5
|
+
export declare function prepareDelivery({ origin, assets, policy }: DeliveryServerOptions): {
|
|
6
|
+
boundary: Readonly<import("@crowsi/browser-security/boundary").OwnerOrigin>;
|
|
7
|
+
authority: SessionAuthority;
|
|
8
|
+
budget: Budget;
|
|
9
|
+
assetMap: Map<string, {
|
|
10
|
+
content: Buffer;
|
|
11
|
+
descriptor: Asset;
|
|
12
|
+
}>;
|
|
13
|
+
ownerPolicy: Readonly<{
|
|
14
|
+
cookieName: string;
|
|
15
|
+
csrfHeader: string;
|
|
16
|
+
nonceHeader: string;
|
|
17
|
+
readinessHeader: string;
|
|
18
|
+
owner: string;
|
|
19
|
+
title: string;
|
|
20
|
+
contract: string;
|
|
21
|
+
allowedOwners: readonly any[];
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
export type Prepared = ReturnType<typeof prepareDelivery>;
|
package/src/prepare.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Budget, limitsFor } from '@crowsi/transport-foundation';
|
|
3
|
+
import { SessionAuthority } from '@crowsi/browser-security/access';
|
|
4
|
+
import { parseOwnerOrigin } from '@crowsi/browser-security/boundary';
|
|
5
|
+
import { bounds } from '@zixcel/view-delivery-contracts';
|
|
6
|
+
import { fail } from './wire.mjs';
|
|
7
|
+
const defaultCookieName = 'view_delivery_session';
|
|
8
|
+
export function prepareDelivery({ origin, assets, policy }) {
|
|
9
|
+
if (!policy || typeof policy.owner !== 'string' || !policy.owner || policy.owner.length > 128 ||
|
|
10
|
+
typeof policy.contract !== 'string' || !policy.contract || policy.contract.length > 128 || typeof policy.title !== 'string' || !policy.title || policy.title.length > 128 || /[<>&]/.test(policy.title) ||
|
|
11
|
+
!Array.isArray(policy.allowedOwners) || policy.allowedOwners.length > 16 || policy.allowedOwners.some(owner => typeof owner !== 'string' || !owner || owner.length > 128))
|
|
12
|
+
fail('InvalidDeliveryPolicy');
|
|
13
|
+
const headerPolicy = { cookieName: policy.cookieName ?? defaultCookieName, csrfHeader: policy.csrfHeader ?? 'x-delivery-csrf', nonceHeader: policy.nonceHeader ?? 'x-delivery-request-nonce', readinessHeader: policy.readinessHeader ?? 'x-delivery-readiness' };
|
|
14
|
+
if (Object.values(headerPolicy).some(value => typeof value !== 'string' || value.length > 64 || !/[a-z]/.test(value) || !/^[a-z0-9_-]+$/.test(value)))
|
|
15
|
+
fail('InvalidDeliveryPolicy');
|
|
16
|
+
const ownerPolicy = Object.freeze({ owner: policy.owner, title: policy.title, contract: policy.contract, allowedOwners: Object.freeze([...policy.allowedOwners]), ...headerPolicy });
|
|
17
|
+
const boundary = parseOwnerOrigin(origin), authority = new SessionAuthority();
|
|
18
|
+
const budget = new Budget(limitsFor(bounds.documentBytes, { pendingMessages: bounds.requests })), assetMap = new Map();
|
|
19
|
+
for (const asset of assets) {
|
|
20
|
+
const { bytes, ...descriptor } = asset, content = Buffer.from(bytes);
|
|
21
|
+
if (content.length !== descriptor.bytesLength || content.length > bounds.assetBytes)
|
|
22
|
+
fail('InvalidAsset');
|
|
23
|
+
const digest = createHash('sha256').update(content).digest('hex');
|
|
24
|
+
if (digest !== descriptor.digest || assetMap.has(descriptor.path) || !/^\/assets\/[a-zA-Z0-9._-]{1,100}$/.test(descriptor.path) || !['script', 'style'].includes(descriptor.kind))
|
|
25
|
+
fail('InvalidAsset');
|
|
26
|
+
assetMap.set(descriptor.path, { content, descriptor: { path: descriptor.path, digest, bytes: content.length, kind: descriptor.kind } });
|
|
27
|
+
}
|
|
28
|
+
if (assetMap.size < 1 || assetMap.size > bounds.assets)
|
|
29
|
+
fail('InvalidAsset');
|
|
30
|
+
return { boundary, authority, budget, assetMap, ownerPolicy };
|
|
31
|
+
}
|
package/src/types.d.mts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { StateReadRequest } from '@crowsi/transport-foundation/state-socket';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import type { Asset, Delivery } from '@zixcel/view-delivery-contracts';
|
|
4
|
+
import type { Json } from '@zixcel/projection-contracts/client';
|
|
5
|
+
export interface RequestContext {
|
|
6
|
+
readonly signal: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export interface DocumentContext extends RequestContext {
|
|
9
|
+
readonly assets: readonly Asset[];
|
|
10
|
+
}
|
|
11
|
+
export interface CommandContext extends RequestContext {
|
|
12
|
+
readonly requestRef: string;
|
|
13
|
+
observe(value: unknown): void;
|
|
14
|
+
}
|
|
15
|
+
export interface DeliverySite {
|
|
16
|
+
document?(url: URL, context: DocumentContext): Promise<{
|
|
17
|
+
envelope: Delivery;
|
|
18
|
+
bootstrap: Readonly<Record<string, Json>>;
|
|
19
|
+
}> | {
|
|
20
|
+
envelope: Delivery;
|
|
21
|
+
bootstrap: Readonly<Record<string, Json>>;
|
|
22
|
+
};
|
|
23
|
+
read?(url: URL, context: RequestContext): unknown | Promise<unknown>;
|
|
24
|
+
command?(url: URL, value: unknown, context: CommandContext): unknown | Promise<unknown>;
|
|
25
|
+
live?(request: StateReadRequest): unknown | Promise<unknown>;
|
|
26
|
+
close?(): void | Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export interface TrustedAsset {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly bytes: Uint8Array;
|
|
31
|
+
readonly bytesLength: number;
|
|
32
|
+
readonly digest: string;
|
|
33
|
+
readonly kind: 'script' | 'style';
|
|
34
|
+
}
|
|
35
|
+
export interface DeliveryServer {
|
|
36
|
+
readonly server: Server;
|
|
37
|
+
notify(): void;
|
|
38
|
+
inspect(): {
|
|
39
|
+
readonly alive: boolean;
|
|
40
|
+
readonly activeRequests: number;
|
|
41
|
+
readonly transport: {
|
|
42
|
+
connections: number;
|
|
43
|
+
sessions: number;
|
|
44
|
+
subscriptions: number;
|
|
45
|
+
pendingBytes: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
listen(): Promise<void>;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface DeliveryPolicy {
|
|
52
|
+
readonly owner: string;
|
|
53
|
+
readonly title: string;
|
|
54
|
+
readonly contract: string;
|
|
55
|
+
readonly allowedOwners: readonly string[];
|
|
56
|
+
readonly cookieName?: string;
|
|
57
|
+
readonly csrfHeader?: string;
|
|
58
|
+
readonly nonceHeader?: string;
|
|
59
|
+
readonly readinessHeader?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface DeliveryServerOptions {
|
|
62
|
+
readonly policy: DeliveryPolicy;
|
|
63
|
+
readonly origin: string;
|
|
64
|
+
readonly site: DeliverySite;
|
|
65
|
+
readonly assets: readonly TrustedAsset[];
|
|
66
|
+
readonly readinessToken?: string | null;
|
|
67
|
+
readonly onFailure?: ((failure: DeliveryFailure) => void) | null;
|
|
68
|
+
readonly onOutcome?: ((outcome: DeliveryOutcome) => void) | null;
|
|
69
|
+
}
|
|
70
|
+
export type OutcomeStage = 'DELIVERY_REJECTED' | 'NOT_DISPATCHED' | 'OWNER_REJECTED' | 'OUTCOME_UNCERTAIN' | 'OWNER_COMMITTED' | 'PUBLICATION_FAILED_AFTER_COMMIT';
|
|
71
|
+
export interface Observation {
|
|
72
|
+
requestRef: string;
|
|
73
|
+
owner: string;
|
|
74
|
+
stage: OutcomeStage;
|
|
75
|
+
receiptRef: string | null;
|
|
76
|
+
operation?: string;
|
|
77
|
+
managementRequestRef?: number | null;
|
|
78
|
+
}
|
|
79
|
+
export interface DeliveryOutcome extends Observation {
|
|
80
|
+
operation: string;
|
|
81
|
+
httpStatus: number | null;
|
|
82
|
+
}
|
|
83
|
+
export interface DeliveryFailure {
|
|
84
|
+
code: string;
|
|
85
|
+
canonical: {
|
|
86
|
+
state: string;
|
|
87
|
+
receiptRef: string | null;
|
|
88
|
+
};
|
|
89
|
+
ownerFailure: {
|
|
90
|
+
owner: string;
|
|
91
|
+
operation: string;
|
|
92
|
+
code: string;
|
|
93
|
+
requirementRef: string;
|
|
94
|
+
outcomeStage: OutcomeStage;
|
|
95
|
+
uncertain: boolean;
|
|
96
|
+
requestRef: string;
|
|
97
|
+
managementRequestRef: number | null;
|
|
98
|
+
receiptRef: string | null;
|
|
99
|
+
detail: Json;
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/src/wire.d.mts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Delivery } from '@zixcel/view-delivery-contracts';
|
|
3
|
+
export declare function fail(code: string): never;
|
|
4
|
+
export declare const record: (v: unknown) => Record<string, unknown>;
|
|
5
|
+
export declare function header(request: IncomingMessage, name: string): string;
|
|
6
|
+
export declare const headers: {
|
|
7
|
+
'content-security-policy': string;
|
|
8
|
+
'referrer-policy': string;
|
|
9
|
+
'x-content-type-options': string;
|
|
10
|
+
'x-frame-options': string;
|
|
11
|
+
'permissions-policy': string;
|
|
12
|
+
'cache-control': string;
|
|
13
|
+
};
|
|
14
|
+
export declare const encode: (value: unknown, maximum?: number) => Buffer<ArrayBuffer>;
|
|
15
|
+
export declare function body(request: IncomingMessage, signal: AbortSignal): Promise<unknown>;
|
|
16
|
+
export declare function sessionId(request: IncomingMessage, cookieName: string): string;
|
|
17
|
+
export declare function safeDocument(envelope: Delivery, bootstrap: unknown, title: string): Buffer<ArrayBuffer>;
|
|
18
|
+
export declare function send(response: ServerResponse, status: number, value: unknown): void;
|
package/src/wire.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { bounds } from '@zixcel/view-delivery-contracts';
|
|
2
|
+
export function fail(code) { throw Object.assign(Error(code), { code }); }
|
|
3
|
+
export const record = (v) => v !== null && typeof v === 'object' ? v : {};
|
|
4
|
+
export function header(request, name) { const v = request.headers[name]; if (v !== undefined && typeof v !== 'string')
|
|
5
|
+
fail('InvalidInput'); return v ?? ''; }
|
|
6
|
+
export const headers = {
|
|
7
|
+
'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
8
|
+
'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY',
|
|
9
|
+
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()', 'cache-control': 'no-store'
|
|
10
|
+
};
|
|
11
|
+
export const encode = (value, maximum = bounds.documentBytes) => {
|
|
12
|
+
// Site/owner outputs are already bounded data. Refuse expansion before sending.
|
|
13
|
+
let nodes = 0, total = 0;
|
|
14
|
+
const add = (bytes) => { total += bytes; if (total > maximum)
|
|
15
|
+
fail('DeliveryLimitExceeded'); };
|
|
16
|
+
function check(v, depth) {
|
|
17
|
+
if (++nodes > 32768 || depth > 20)
|
|
18
|
+
fail('DeliveryLimitExceeded');
|
|
19
|
+
if (typeof v === 'string') {
|
|
20
|
+
if (v.length > maximum)
|
|
21
|
+
fail('DeliveryLimitExceeded');
|
|
22
|
+
add(Buffer.byteLength(v) + 2);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (v === null || typeof v === 'boolean') {
|
|
26
|
+
add(5);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
30
|
+
add(24);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!v || typeof v !== 'object' || Object.getPrototypeOf(v) !== (Array.isArray(v) ? Array.prototype : Object.prototype) || Object.hasOwn(v, 'toJSON'))
|
|
34
|
+
fail('InvalidDeliveryData');
|
|
35
|
+
if (Array.isArray(v) && v.length > 4096)
|
|
36
|
+
fail('DeliveryLimitExceeded');
|
|
37
|
+
const keys = Object.keys(v);
|
|
38
|
+
if (Array.isArray(v) && (keys.length !== v.length || keys.some((key, index) => key !== String(index))))
|
|
39
|
+
fail('InvalidDeliveryData');
|
|
40
|
+
if (keys.length > 4096)
|
|
41
|
+
fail('DeliveryLimitExceeded');
|
|
42
|
+
add(keys.length + 2);
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const d = Object.getOwnPropertyDescriptor(v, key);
|
|
45
|
+
if (!d || d.get || d.set || key === 'toJSON')
|
|
46
|
+
fail('InvalidDeliveryData');
|
|
47
|
+
add(Buffer.byteLength(key) + 3);
|
|
48
|
+
check(d.value, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
check(value, 0);
|
|
52
|
+
const bytes = Buffer.from(JSON.stringify(value));
|
|
53
|
+
if (bytes.length > maximum)
|
|
54
|
+
fail('DeliveryLimitExceeded');
|
|
55
|
+
return bytes;
|
|
56
|
+
};
|
|
57
|
+
export async function body(request, signal) {
|
|
58
|
+
const h = request.headers, raw = h['content-length'];
|
|
59
|
+
if (h['content-encoding'] || h['transfer-encoding'] || !/^application\/json(?:\s*;.*)?$/i.test(h['content-type'] ?? '') || !(/^[1-9][0-9]{0,5}$/).test(raw ?? ''))
|
|
60
|
+
fail('InvalidInput');
|
|
61
|
+
const size = Number(raw);
|
|
62
|
+
if (size > bounds.requestBytes)
|
|
63
|
+
fail('DeliveryLimitExceeded');
|
|
64
|
+
const data = Buffer.alloc(size);
|
|
65
|
+
let used = 0;
|
|
66
|
+
for await (const chunk of request) {
|
|
67
|
+
if (!Buffer.isBuffer(chunk))
|
|
68
|
+
fail('InvalidInput');
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
fail('DeliveryCancelled');
|
|
71
|
+
if (used + chunk.length > size)
|
|
72
|
+
fail('DeliveryLimitExceeded');
|
|
73
|
+
chunk.copy(data, used);
|
|
74
|
+
used += chunk.length;
|
|
75
|
+
}
|
|
76
|
+
if (used !== size)
|
|
77
|
+
fail('InvalidInput');
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
fail('InvalidInput');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function sessionId(request, cookieName) {
|
|
86
|
+
const cookie = request.headers.cookie ?? '';
|
|
87
|
+
if (cookie.length > 8192)
|
|
88
|
+
fail('SessionRejected');
|
|
89
|
+
const values = cookie.split(';').map(s => s.trim()).filter(s => s.startsWith(cookieName + '='));
|
|
90
|
+
if (values.length !== 1)
|
|
91
|
+
fail('SessionRejected');
|
|
92
|
+
return values[0].slice(cookieName.length + 1);
|
|
93
|
+
}
|
|
94
|
+
export function safeDocument(envelope, bootstrap, title) {
|
|
95
|
+
const serialized = encode({ envelope, bootstrap }).toString().replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
96
|
+
const styles = envelope.assets.filter(a => a.kind === 'style').map(a => `<link rel="stylesheet" href="${a.path}">`).join('');
|
|
97
|
+
const scripts = envelope.assets.filter(a => a.kind === 'script').map(a => `<script type="module" src="${a.path}"></script>`).join('');
|
|
98
|
+
return Buffer.from(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>${styles}</head><body><main id="app" aria-busy="true"><p role="status">Loading exact state…</p></main><script id="delivery-state" type="application/json">${serialized}</script>${scripts}</body></html>`);
|
|
99
|
+
}
|
|
100
|
+
export function send(response, status, value) { const bytes = encode(value); response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': bytes.length }); response.end(bytes); }
|