@human-synthesis/norns 0.1.0 → 0.2.1
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/package.json +1 -1
- package/src/kernel/address.js +4 -0
- package/src/kernel/emit-units.js +283 -7
- package/src/kernel/emit-wrangler.js +22 -2
- package/src/kernel/expr-compile.js +4 -1
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +500 -23
- package/src/kernel/index.js +9 -2
- package/src/kernel/meta.js +145 -1
- package/src/kernel/refine.js +78 -0
- package/src/kernel/trace.js +215 -27
- package/src/live-client.js +144 -0
- package/src/server/boot.js +22 -0
- package/src/server/db.js +5 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/index.js +3 -0
- package/src/server/job.js +102 -0
- package/src/server/room.js +17 -0
- package/src/server/service.js +188 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service client runtime (D15/K-21, R-15).
|
|
3
|
+
*
|
|
4
|
+
* The generated `lib/<m>/services.c` calls `serviceClient(def)` with the
|
|
5
|
+
* spec-derived manifest; each declared operation becomes
|
|
6
|
+
* `client.<op>(args, container)`.
|
|
7
|
+
*
|
|
8
|
+
* Credentials resolve at call time, never from spec: `def.auth.binding`
|
|
9
|
+
* names an env binding looked up on the container's `env` token when one is
|
|
10
|
+
* bound (Cloudflare per-request scope) and `process.env` otherwise (dev).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class ServiceError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} service unit address (`crm.Service.mailer`)
|
|
16
|
+
* @param {string} operation
|
|
17
|
+
* @param {number} status HTTP status (0 for transport failures)
|
|
18
|
+
* @param {*} body parsed response body (or error message)
|
|
19
|
+
*/
|
|
20
|
+
constructor(service, operation, status, body) {
|
|
21
|
+
super(`${service}.${operation} failed with ${status}`);
|
|
22
|
+
this.name = 'ServiceError';
|
|
23
|
+
this.service = service;
|
|
24
|
+
this.operation = operation;
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.body = body;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Env for credential lookups: container `env` token when bound, else process.env. */
|
|
31
|
+
export function envOf(container) {
|
|
32
|
+
if (container?.has?.('env')) return container.resolve('env') ?? {};
|
|
33
|
+
return globalThis.process?.env ?? {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function credential(def, container) {
|
|
37
|
+
const { mode, binding } = def.auth ?? { mode: 'none' };
|
|
38
|
+
if (mode === 'none') return null;
|
|
39
|
+
const value = envOf(container)[binding];
|
|
40
|
+
if (!value) throw new Error(`Service ${def.name}: env binding "${binding}" is not set`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function authHeaders(def, container, bodyText) {
|
|
45
|
+
const { mode, header } = def.auth ?? { mode: 'none' };
|
|
46
|
+
if (mode === 'none') return {};
|
|
47
|
+
const value = credential(def, container);
|
|
48
|
+
switch (mode) {
|
|
49
|
+
case 'bearer':
|
|
50
|
+
return { authorization: `Bearer ${value}` };
|
|
51
|
+
case 'basic':
|
|
52
|
+
return { authorization: `Basic ${btoa(value)}` };
|
|
53
|
+
case 'header':
|
|
54
|
+
return { [header]: value };
|
|
55
|
+
case 'hmac':
|
|
56
|
+
return { 'x-signature': await hmacHex(value, bodyText ?? '') };
|
|
57
|
+
default:
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** HMAC-SHA-256 hex of `text` keyed by `secret` — signing and inbound verification. */
|
|
63
|
+
export async function hmacHex(secret, text) {
|
|
64
|
+
const enc = new TextEncoder();
|
|
65
|
+
const key = await crypto.subtle.importKey(
|
|
66
|
+
'raw',
|
|
67
|
+
enc.encode(secret),
|
|
68
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
69
|
+
false,
|
|
70
|
+
['sign']
|
|
71
|
+
);
|
|
72
|
+
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(text));
|
|
73
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TYPE_OK = {
|
|
77
|
+
text: (x) => typeof x === 'string',
|
|
78
|
+
email: (x) => typeof x === 'string' && x.includes('@'),
|
|
79
|
+
url: (x) => typeof x === 'string',
|
|
80
|
+
date: (x) => typeof x === 'string' || x instanceof Date,
|
|
81
|
+
datetime: (x) => typeof x === 'string' || x instanceof Date,
|
|
82
|
+
int: (x) => Number.isInteger(x),
|
|
83
|
+
number: (x) => typeof x === 'number',
|
|
84
|
+
money: (x) => typeof x === 'number',
|
|
85
|
+
bool: (x) => typeof x === 'boolean',
|
|
86
|
+
json: () => true
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Contract check for an operation's input/output shape (spec field types; entity refs pass through). */
|
|
90
|
+
export function shapeIssues(shape, value, label) {
|
|
91
|
+
const issues = [];
|
|
92
|
+
for (const [key, t] of Object.entries(shape ?? {})) {
|
|
93
|
+
const spec = typeof t === 'string' ? t : (t?.type ?? 'json');
|
|
94
|
+
const optional = typeof t === 'string' ? t.endsWith('?') : t?.optional === true;
|
|
95
|
+
const type = spec.replace(/\?$/, '');
|
|
96
|
+
const val = value?.[key];
|
|
97
|
+
if (val === undefined || val === null) {
|
|
98
|
+
if (!optional) issues.push(`missing ${label} "${key}"`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!(TYPE_OK[type] ?? (() => true))(val)) issues.push(`${label} "${key}": expected ${type}`);
|
|
102
|
+
}
|
|
103
|
+
return issues;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a typed client from a Service manifest.
|
|
108
|
+
*
|
|
109
|
+
* @param {{
|
|
110
|
+
* name: string,
|
|
111
|
+
* base: string,
|
|
112
|
+
* auth?: { mode: 'none'|'bearer'|'basic'|'hmac'|'header', binding?: string, header?: string },
|
|
113
|
+
* timeoutMs?: number,
|
|
114
|
+
* operations: Record<string, { method?: string, path?: string, input?: Record<string, *>, output?: * }>
|
|
115
|
+
* }} def
|
|
116
|
+
* @param {typeof fetch} [fetchImpl] injectable for tests
|
|
117
|
+
* @returns {Record<string, (args?: Record<string, *>, container?: *) => Promise<*>>}
|
|
118
|
+
*/
|
|
119
|
+
export function serviceClient(def, fetchImpl) {
|
|
120
|
+
const client = {};
|
|
121
|
+
for (const [opName, op] of Object.entries(def.operations ?? {})) {
|
|
122
|
+
client[opName] = async (args = {}, container) => {
|
|
123
|
+
const inputIssues = shapeIssues(op.input, args, 'input');
|
|
124
|
+
if (inputIssues.length > 0) {
|
|
125
|
+
throw new Error(`Service ${def.name}.${opName}: ${inputIssues.join('; ')}`);
|
|
126
|
+
}
|
|
127
|
+
// Op-level container override — trace fixtures and tests bind
|
|
128
|
+
// `<service address>.<op>` to bypass the network after validation.
|
|
129
|
+
const fixtureKey = `${def.name}.${opName}`;
|
|
130
|
+
if (container?.has?.(fixtureKey)) {
|
|
131
|
+
return await container.resolve(fixtureKey)(args);
|
|
132
|
+
}
|
|
133
|
+
const method = (op.method ?? 'POST').toUpperCase();
|
|
134
|
+
const rest = { ...args };
|
|
135
|
+
const path = (op.path ?? `/${opName}`).replace(
|
|
136
|
+
/\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
|
|
137
|
+
(_, k) => {
|
|
138
|
+
delete rest[k];
|
|
139
|
+
return encodeURIComponent(String(args[k]));
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
let url = def.base.replace(/\/$/, '') + path;
|
|
143
|
+
let bodyText;
|
|
144
|
+
if (method === 'GET' || method === 'DELETE') {
|
|
145
|
+
const qs = new URLSearchParams();
|
|
146
|
+
for (const [k, val] of Object.entries(rest)) {
|
|
147
|
+
if (val !== undefined && val !== null) qs.set(k, String(val));
|
|
148
|
+
}
|
|
149
|
+
const q = qs.toString();
|
|
150
|
+
if (q) url += `?${q}`;
|
|
151
|
+
} else {
|
|
152
|
+
bodyText = JSON.stringify(rest);
|
|
153
|
+
}
|
|
154
|
+
const headers = {
|
|
155
|
+
accept: 'application/json',
|
|
156
|
+
...(bodyText !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
157
|
+
...(await authHeaders(def, container, bodyText))
|
|
158
|
+
};
|
|
159
|
+
let res;
|
|
160
|
+
try {
|
|
161
|
+
res = await (fetchImpl ?? fetch)(url, {
|
|
162
|
+
method,
|
|
163
|
+
headers,
|
|
164
|
+
...(bodyText !== undefined ? { body: bodyText } : {}),
|
|
165
|
+
signal: AbortSignal.timeout(def.timeoutMs ?? 10_000)
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new ServiceError(def.name, opName, 0, String(err?.message ?? err));
|
|
169
|
+
}
|
|
170
|
+
const text = await res.text();
|
|
171
|
+
let data;
|
|
172
|
+
try {
|
|
173
|
+
data = text ? JSON.parse(text) : null;
|
|
174
|
+
} catch {
|
|
175
|
+
data = text;
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) throw new ServiceError(def.name, opName, res.status, data);
|
|
178
|
+
if (op.output && typeof op.output === 'object') {
|
|
179
|
+
const outIssues = shapeIssues(op.output, data, 'output');
|
|
180
|
+
if (outIssues.length > 0) {
|
|
181
|
+
throw new ServiceError(def.name, opName, res.status, { contract: outIssues, data });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return client;
|
|
188
|
+
}
|