@pulse-compute/provider-node 1.0.0-beta.1 → 1.0.0-beta.2

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 CHANGED
@@ -6,10 +6,11 @@
6
6
  > **Install directly:** No for application projects; select provider: node through the CLI.<br>
7
7
  > **Supported entry points:** `@pulse-compute/provider-node/toolchain`<br>
8
8
  > **Stability:** The listed built-in toolchain entry is synchronized with the Beta bootstrap contract; other canonical Node provider interfaces remain internal.<br>
9
+ > **npm:** [`@pulse-compute/provider-node`](https://www.npmjs.com/package/@pulse-compute/provider-node)<br>
9
10
  > **Canonical replacement:** `provider: 'node' through @pulse-compute/cli`<br>
10
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.1/packages/implementation-packages/)
11
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.2/packages/implementation-packages/)
11
12
  >
12
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.1` package policy.
13
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.2` package policy.
13
14
  <!-- pulse-package-status:end -->
14
15
 
15
16
  Canonical Node provider implementation used internally when a project selects `provider: 'node'`.
@@ -59,4 +60,4 @@ The package exposes focused compiler and runtime modules for the synchronized
59
60
  repository, including `runtime/canonical-api-runtime`. Those exports are
60
61
  implementation interfaces, not application-author APIs.
61
62
 
62
- See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.1/concepts/contracts-and-providers/) and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.1/packages/implementation-packages/).
63
+ See [Contracts and providers](https://pulsecompute.io/v1.0.0-beta.2/concepts/contracts-and-providers/) and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.2/packages/implementation-packages/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/provider-node",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": "^22.14.0 || ^24.0.0"
@@ -42,12 +42,13 @@
42
42
  "./javascript/source-package": "./src/javascript/source-package.js"
43
43
  },
44
44
  "dependencies": {
45
- "@pulse-compute/crypto": "1.0.0-beta.1",
46
- "@pulse-compute/jwt": "1.0.0-beta.1",
47
- "@pulse-compute/runtime": "1.0.0-beta.1",
48
- "@pulse-compute/wasm-build-support": "1.0.0-beta.1",
49
- "@pulse-compute/wasm-contracts": "1.0.0-beta.1",
50
- "@pulse-compute/wasm-host-runtime": "1.0.0-beta.1"
45
+ "@pulse-compute/crypto": "1.0.0-beta.2",
46
+ "@pulse-compute/jwt": "1.0.0-beta.2",
47
+ "@pulse-compute/runtime": "1.0.0-beta.2",
48
+ "@pulse-compute/s3": "1.0.0-beta.2",
49
+ "@pulse-compute/wasm-build-support": "1.0.0-beta.2",
50
+ "@pulse-compute/wasm-contracts": "1.0.0-beta.2",
51
+ "@pulse-compute/wasm-host-runtime": "1.0.0-beta.2"
51
52
  },
52
53
  "publishConfig": {
53
54
  "access": "public"
@@ -63,7 +64,7 @@
63
64
  "url": "git+https://github.com/pulse-compute/pulse.git",
64
65
  "directory": "packages/provider-node"
65
66
  },
66
- "homepage": "https://pulsecompute.io/v1.0.0-beta.1/packages/implementation-packages/",
67
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.2/packages/implementation-packages/",
67
68
  "bugs": {
68
69
  "url": "https://github.com/pulse-compute/pulse/issues"
69
70
  }
@@ -3,6 +3,7 @@
3
3
  const NODE_JWT_VERIFY_CAPABILITY = 'jwt.verify';
4
4
 
5
5
  const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
6
+ 's3.head', 's3.getText', 's3.putText',
6
7
  'request',
7
8
  'response.json',
8
9
  'response.text',
@@ -12,6 +13,7 @@ const NODE_CANONICAL_PROVIDER_CAPABILITIES = Object.freeze([
12
13
  'secret.get',
13
14
  'kv.get',
14
15
  'kv.put',
16
+ 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap',
15
17
  'event.ingress',
16
18
  'event.emit',
17
19
  'assets.lookup',
@@ -30,8 +32,14 @@ const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
30
32
  'secret.get': 'node.runtime.secret.get',
31
33
  'kv.get': 'node.runtime.kv.get',
32
34
  'kv.put': 'node.runtime.kv.put',
35
+ 'kv.getVersioned': 'node.runtime.kv.getVersioned',
36
+ 'kv.insertIfAbsent': 'node.runtime.kv.insertIfAbsent',
37
+ 'kv.compareAndSwap': 'node.runtime.kv.compareAndSwap',
33
38
  'event.ingress': 'node.event.reference.ingress',
34
39
  'event.emit': 'node.event.reference.accept',
40
+ 's3.head': 'node.native.s3.head',
41
+ 's3.getText': 'node.native.s3.getText',
42
+ 's3.putText': 'node.native.s3.putText',
35
43
  'assets.lookup': 'node.runtime.assets.lookup',
36
44
  'grip.broadcast': 'node.http.grip.publish',
37
45
  [NODE_JWT_VERIFY_CAPABILITY]: 'node.native.jwt.verify',
@@ -39,6 +47,7 @@ const NODE_CANONICAL_PROVIDER_LOWERING = Object.freeze({
39
47
  });
40
48
 
41
49
  const NODE_NATIVE_TARGET_CAPABILITIES = Object.freeze([
50
+ 's3.head', 's3.getText', 's3.putText',
42
51
  NODE_JWT_VERIFY_CAPABILITY,
43
52
  'jwt.verify.hs256',
44
53
  'jwt.verify.es256',
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+ const { normalizeBinding, namePattern } = require('@pulse-compute/s3/provider');
3
+ function normalizeNodeS3(input = {}) {
4
+ if (!input || typeof input !== 'object' || Array.isArray(input) || ![null, Object.prototype].includes(Object.getPrototypeOf(input))) throw new TypeError('Node S3 bindings must be a map.');
5
+ for (const key of Reflect.ownKeys(input)) {
6
+ const field = Object.getOwnPropertyDescriptor(input, key);
7
+ if (typeof key !== 'string' || !field.enumerable || !Object.hasOwn(field, 'value')) throw new TypeError('Node S3 bindings require data properties.');
8
+ }
9
+ return Object.freeze(Object.fromEntries(Object.entries(input).map(([name, value]) => {
10
+ if (!namePattern.test(name)) throw new TypeError('Node S3 logical binding name is invalid.');
11
+ return [name, normalizeBinding(value)];
12
+ })));
13
+ }
14
+ function validateNodeS3Operations(metadata, bindings) {
15
+ for (const effect of metadata.providerOperations || []) {
16
+ if (!['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) continue;
17
+ if (!bindings.s3 || !Object.hasOwn(bindings.s3, effect.resource && effect.resource.binding)) throw new TypeError('The required Node S3 binding is missing.');
18
+ }
19
+ }
20
+ module.exports = { normalizeNodeS3, validateNodeS3Operations };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { createNodeKvReference } = require('../runtime/conditional-kv.js');
3
4
  const runtimeHost = require('@pulse-compute/runtime/host');
4
5
 
5
6
  const NODE_JAVASCRIPT_BINDINGS_ADAPTER_VERSION = 'pulse.node-javascript-bindings-adapter.v1';
@@ -31,7 +32,7 @@ function recordEntries(input, label = 'binding record') {
31
32
  function createNodeJavascriptBindingCapabilities(options = {}) {
32
33
  const config = new Map();
33
34
  const secrets = new Map();
34
- const stores = new Map();
35
+ const reference = options.kvReference || createNodeKvReference(options);
35
36
 
36
37
  for (const [rawName, rawValue] of recordEntries(options.config, 'config')) {
37
38
  const name = runtimeHost.normalizeBindingName('config', rawName, options);
@@ -41,22 +42,6 @@ function createNodeJavascriptBindingCapabilities(options = {}) {
41
42
  const name = runtimeHost.normalizeBindingName('secret', rawName, options);
42
43
  secrets.set(name, runtimeHost.normalizeBindingValue('secret.get', name, rawValue, options));
43
44
  }
44
- for (const [rawNamespace, values] of recordEntries(options.kv, 'KV namespaces')) {
45
- const namespace = runtimeHost.normalizeKvNamespace(rawNamespace, options);
46
- const entries = new Map();
47
- for (const [rawKey, value] of recordEntries(values, `KV namespace ${JSON.stringify(namespace)}`)) {
48
- const key = runtimeHost.normalizeKvKey(rawKey, options);
49
- entries.set(key, runtimeHost.cloneKvValue(value, options));
50
- }
51
- stores.set(namespace, entries);
52
- }
53
-
54
- function storeFor(namespaceInput) {
55
- const namespace = runtimeHost.normalizeKvNamespace(namespaceInput, options);
56
- if (!stores.has(namespace)) stores.set(namespace, new Map());
57
- return Object.freeze({ namespace, store: stores.get(namespace) });
58
- }
59
-
60
45
  return Object.freeze({
61
46
  version: NODE_JAVASCRIPT_BINDINGS_ADAPTER_VERSION,
62
47
  config(nameInput) {
@@ -67,23 +52,8 @@ function createNodeJavascriptBindingCapabilities(options = {}) {
67
52
  const name = runtimeHost.normalizeBindingName('secret', nameInput, options);
68
53
  return secrets.has(name) ? secrets.get(name) : undefined;
69
54
  },
70
- kv(namespaceInput) {
71
- const { store } = storeFor(namespaceInput);
72
- return Object.freeze({
73
- get(keyInput) {
74
- const key = runtimeHost.normalizeKvKey(keyInput, options);
75
- return runtimeHost.cloneKvValue(store.has(key) ? store.get(key) : undefined, {
76
- ...options,
77
- allowUndefined: true
78
- });
79
- },
80
- put(keyInput, value) {
81
- const key = runtimeHost.normalizeKvKey(keyInput, options);
82
- store.set(key, runtimeHost.cloneKvValue(value, options));
83
- return true;
84
- }
85
- });
86
- }
55
+ prepareConditionalKv: reference.prepareConditionalKv,
56
+ kv: reference.kv
87
57
  });
88
58
  }
89
59
 
@@ -97,7 +67,7 @@ function withNodeBindingCapabilities(capabilities, options = {}) {
97
67
  const configured = options.bindings || createNodeJavascriptBindingCapabilities(options);
98
68
  if (needsConfig) output.config = configured.config;
99
69
  if (needsSecret) output.secret = configured.secret;
100
- if (needsKv) output.kv = configured.kv;
70
+ if (needsKv) { output.kv = configured.kv; output.prepareConditionalKv = configured.prepareConditionalKv; }
101
71
  return Object.freeze(output);
102
72
  }
103
73
 
@@ -179,6 +179,7 @@ function createNodeJavascriptHandler(application, options = {}) {
179
179
  config: options.config,
180
180
  secrets: options.secrets,
181
181
  kv: options.kv,
182
+ kvReference: options.kvReference,
182
183
  maxBindingNameBytes: options.maxBindingNameBytes,
183
184
  maxBindingValueBytes: options.maxBindingValueBytes,
184
185
  maxKvNamespaceBytes: options.maxKvNamespaceBytes,
@@ -203,6 +204,7 @@ function createNodeJavascriptHandler(application, options = {}) {
203
204
  const kv = hasDynamicBindings ? await resolveRequestOption(options.kv, requestContext) : options.kv;
204
205
  const bindings = staticBindings || createNodeJavascriptBindingCapabilities({
205
206
  config, secrets, kv,
207
+ kvReference: options.kvReference,
206
208
  maxBindingNameBytes: options.maxBindingNameBytes,
207
209
  maxBindingValueBytes: options.maxBindingValueBytes,
208
210
  maxKvNamespaceBytes: options.maxKvNamespaceBytes,
@@ -234,6 +236,7 @@ function createNodeJavascriptHandler(application, options = {}) {
234
236
  assets: options.assets,
235
237
  grip: options.grip,
236
238
  maxEffects: options.maxEffects,
239
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
237
240
  maxRequestBodyBytes: options.maxRequestBodyBytes ?? options.maxBodyBytes,
238
241
  maxFetchBodyBytes: options.maxFetchBodyBytes,
239
242
  maxStructuredBodyBytes: options.maxStructuredBodyBytes,
@@ -31,9 +31,14 @@ function createNodeJavascriptPackageEffectCapabilities(options = {}) {
31
31
  const assetsLookup = options.assetsLookup || (options.assets && options.assets.lookup);
32
32
  const gripBroadcast = options.gripBroadcast || (options.grip && options.grip.broadcast);
33
33
  const jwtVerify = options.jwtVerify || (options.jwt && options.jwt.verify);
34
+ const s3 = options.s3;
34
35
  return Object.freeze({
35
36
  version: NODE_JAVASCRIPT_PACKAGE_EFFECTS_VERSION,
36
37
  async effect(effect, execution) {
38
+ if (effect && effect.contractId === 'pulse.s3') {
39
+ if (typeof s3 !== 'function') return unavailable(effect);
40
+ return s3(effect, execution);
41
+ }
37
42
  if (effect && effect.contractId === 'pulse.assets' && effect.operation === 'lookup') {
38
43
  if (typeof assetsLookup !== 'function') return unavailable(effect);
39
44
  return assetsLookup(effect.payload, execution);
@@ -61,6 +66,7 @@ function withNodePackageEffectCapabilities(capabilities, options = {}) {
61
66
  effect.contractId === 'pulse.assets'
62
67
  || effect.contractId === 'pulse.grip'
63
68
  || effect.contractId === 'pulse.jwt'
69
+ || effect.contractId === 'pulse.s3'
64
70
  )) {
65
71
  return packageCapabilities.effect(effect, execution);
66
72
  }
@@ -10,6 +10,7 @@ const { createNodeJavascriptFixtureFetch, withNodeFetchCapability } = require('.
10
10
  const { createNodeJavascriptAssetsLookup } = require('./assets-adapter.js');
11
11
  const { createNodeJavascriptGripBroadcast } = require('./grip-broadcast.js');
12
12
  const { createNodeJavascriptJwtVerify } = require('./jwt-verifier.js');
13
+ const { createNodeJavascriptS3 } = require('./s3.js');
13
14
  const { createNodeJavascriptPackageEffectCapabilities, withNodePackageEffectCapabilities } = require('./package-effects.js');
14
15
  const { NODE_JAVASCRIPT_TARGET_DESCRIPTOR } = require('./target.js');
15
16
 
@@ -34,6 +35,7 @@ function nodeJavascriptProviderCapabilities(options) {
34
35
  ...(Object.prototype.hasOwnProperty.call(options, 'config') ? { config: options.config } : {}),
35
36
  ...(Object.prototype.hasOwnProperty.call(options, 'secrets') ? { secrets: options.secrets } : {}),
36
37
  ...(Object.prototype.hasOwnProperty.call(options, 'kv') ? { kv: options.kv } : {}),
38
+ kvReference: options.kvReference, kvInstanceId: options.kvInstanceId, kvGenerationStart: options.kvGenerationStart, kvHooks: options.kvHooks,
37
39
  maxBindingNameBytes: options.maxBindingNameBytes,
38
40
  maxBindingValueBytes: options.maxBindingValueBytes,
39
41
  maxKvNamespaceBytes: options.maxKvNamespaceBytes,
@@ -65,6 +67,7 @@ function nodeJavascriptProviderCapabilities(options) {
65
67
  assetsLookup: configuredAssetsLookup,
66
68
  gripBroadcast: configuredGripBroadcast,
67
69
  jwtVerify: configuredJwtVerify,
70
+ s3: createNodeJavascriptS3(options, bindingCapabilities.secret),
68
71
  assets: options.assets,
69
72
  grip: options.grip
70
73
  }
@@ -87,6 +90,7 @@ async function executeNodeJavascriptApplication(application, request, options =
87
90
  application: options.application,
88
91
  requestHeaders: options.requestHeaders,
89
92
  signal: options.signal,
93
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
90
94
  maxEffects: options.maxEffects,
91
95
  maxRequestBodyBytes: options.maxRequestBodyBytes,
92
96
  maxFetchBodyBytes: options.maxFetchBodyBytes,
@@ -118,6 +122,7 @@ async function executeNodeJavascriptEvent(application, frame, options = {}) {
118
122
  effectAdapter: options.effectAdapter,
119
123
  application: options.application,
120
124
  signal: options.signal,
125
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
121
126
  maxEffects: options.maxEffects,
122
127
  maxBindingNameBytes: options.maxBindingNameBytes,
123
128
  maxBindingValueBytes: options.maxBindingValueBytes,
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ const { bindJavascriptDigestMac } = require('@pulse-compute/crypto/provider');
3
+ const { normalizeNodeS3 } = require('../config/s3.js');
4
+ const { readS3 } = require('../runtime/s3-reader.js');
5
+
6
+ function createNodeJavascriptS3(options, secretLookup) {
7
+ const s3 = normalizeNodeS3(options.s3 || options.bindings && options.bindings.s3);
8
+ return (effect, execution = {}) => {
9
+ // This provider target explicitly selects Crypto's Web Crypto realization.
10
+ // Native execution supplies its own Wasm exports to the same Node transport.
11
+ let selected;
12
+ try { selected = bindJavascriptDigestMac(['SHA-256', 'HMAC-SHA256']); }
13
+ catch { selected = null; }
14
+ return readS3(effect, {
15
+ s3, signal: execution.signal, registerRedactionValue: execution.registerRedactionValue,
16
+ fetchImplementation: options.fetchImplementation, cryptoTarget: 'javascript',
17
+ cryptoVerifier: selected, cryptoRealization: selected && selected.realization
18
+ }, (name) => secretLookup(name, execution));
19
+ };
20
+ }
21
+ module.exports = { createNodeJavascriptS3 };
@@ -10,6 +10,7 @@ function decision(id, status, reasonId, owner, required = true) {
10
10
 
11
11
  function classifyNodeJavascriptCapability(id, options = {}) {
12
12
  const capability = String(id);
13
+ if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
13
14
  if (isJavascriptCoreCapability(capability)) {
14
15
  return decision(capability, 'eligible', 'node-router-context-parity', 'provider-node');
15
16
  }
@@ -75,6 +76,7 @@ function classifyNodeJavascriptCapability(id, options = {}) {
75
76
 
76
77
  function classifyNodeJavascriptProviderRequirement(id, compilerCapabilities, options = {}) {
77
78
  const requirement = String(id);
79
+ if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'eligible', 'node-s3-bounded-origin-transport', 'provider-node');
78
80
  if (requirement === 'request') {
79
81
  const bodyRequired = compilerCapabilities.some((entry) => entry === 'request.json' || entry.startsWith('request.body'));
80
82
  return bodyRequired
@@ -87,7 +89,7 @@ function classifyNodeJavascriptProviderRequirement(id, compilerCapabilities, opt
87
89
  if (requirement === 'fetch' || requirement === 'opaque.pass-through' || ['body', 'headers', 'result'].includes(requirement)) {
88
90
  return decision(requirement, 'eligible', 'javascript-fetch-projection-realization', 'runtime');
89
91
  }
90
- if (['config.get', 'secret.get', 'kv.get', 'kv.put'].includes(requirement)) {
92
+ if (['config.get', 'secret.get', 'kv.get', 'kv.put', 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(requirement)) {
91
93
  return options.bindingsRedaction === true
92
94
  ? decision(requirement, 'eligible', 'javascript-binding-redaction-implemented', 'provider-node')
93
95
  : decision(requirement, 'pending', 'javascript-binding-effect-pending', 'provider-node');
@@ -17,7 +17,7 @@ const NODE_JAVASCRIPT_TARGET_DESCRIPTOR = normalizeJavascriptTargetDescriptor({
17
17
  },
18
18
  crypto: defineCryptoTargetCapabilities({
19
19
  target: 'javascript',
20
- algorithms: [{
20
+ algorithms: [...['SHA-256', 'HMAC-SHA256'].map((algorithm) => ({ algorithm, realization: 'runtime-builtin', implemented: true, status: 'implemented-o3' })), {
21
21
  algorithm: 'HS256',
22
22
  realization: 'runtime-builtin',
23
23
  implemented: true,
@@ -52,6 +52,7 @@ async function executeNodeJavascriptTestCase(application, testCase, options = {}
52
52
  jwtCaptureWallClock: options.jwtCaptureWallClock,
53
53
  config: testCase.config || {},
54
54
  secrets: testCase.secrets || {},
55
+ s3: options.s3 || options.bindings && options.bindings.s3,
55
56
  kv: testCase.kv || {},
56
57
  application: options.application,
57
58
  requestHeaders: adapted.headerPairs,
@@ -60,7 +60,7 @@ const NODE_NATIVE_TARGET_DESCRIPTOR = Object.freeze({
60
60
  automaticFallback: false,
61
61
  crypto: defineCryptoTargetCapabilities({
62
62
  target: 'native',
63
- algorithms: [{
63
+ algorithms: [...['SHA-256', 'HMAC-SHA256'].map((algorithm) => ({ algorithm, realization: 'guest-source:pulse-hmac-as', implemented: true, status: 'implemented-o2' })), {
64
64
  algorithm: 'HS256',
65
65
  realization: 'guest-source:pulse-hmac-as',
66
66
  implemented: true,
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { createNodeKvReference } = require('./conditional-kv.js');
2
3
 
3
4
  const path = require('node:path');
4
5
  const { Readable } = require('node:stream');
@@ -361,6 +362,7 @@ function effectToProviderEffect(normalized) {
361
362
 
362
363
  function createNodeProviderAdapter(baseOptions = {}) {
363
364
  const kvExecutions = new Map();
365
+ const sharedKv = baseOptions.kvReference || createNodeKvReference(baseOptions);
364
366
  const verifyNativeJwt = createNodeNativeJwtVerify({
365
367
  captureWallClock: baseOptions.captureJwtWallClock,
366
368
  secretLookup(name, executionOptions) {
@@ -449,29 +451,14 @@ function createNodeProviderAdapter(baseOptions = {}) {
449
451
  };
450
452
  }
451
453
 
452
- function kvStoresFor(executionOptions) {
454
+ function kvReferenceFor(executionOptions) {
455
+ if (executionOptions.kvReference) return executionOptions.kvReference;
456
+ if (!Object.prototype.hasOwnProperty.call(executionOptions, 'kv') || executionOptions.kv === baseOptions.kv) return sharedKv;
453
457
  const executionId = String(executionOptions.executionId || 'default');
454
- if (!kvExecutions.has(executionId)) {
455
- const stores = new Map();
456
- const limits = bindingOptions(executionOptions);
457
- for (const [rawName, values] of recordEntries(valuesFor(executionOptions, 'kv'), 'KV namespace record')) {
458
- const name = portableRuntimeHost.normalizeKvNamespace(rawName, limits);
459
- const entries = new Map();
460
- for (const [rawKey, value] of recordEntries(values, `KV namespace ${JSON.stringify(name)}`)) {
461
- const key = portableRuntimeHost.normalizeKvKey(rawKey, limits);
462
- entries.set(key, portableRuntimeHost.cloneKvValue(value, {
463
- operation: 'seed',
464
- maxBytes: limits.maxKvValueBytes,
465
- maxDepth: limits.maxKvValueDepth,
466
- maxEntries: limits.maxKvValueEntries
467
- }));
468
- }
469
- stores.set(name, entries);
470
- }
471
- kvExecutions.set(executionId, stores);
472
- }
458
+ if (!kvExecutions.has(executionId)) kvExecutions.set(executionId, createNodeKvReference({ ...baseOptions, ...executionOptions, ...bindingOptions(executionOptions) }));
473
459
  return kvExecutions.get(executionId);
474
460
  }
461
+ function kvStoresFor(executionOptions) { return kvReferenceFor(executionOptions).legacyStores(); }
475
462
 
476
463
  async function dispatchFetch(normalized, executionOptions = {}) {
477
464
  const fetchFixtures = normalizeFetchFixtures(executionOptions.fetches || baseOptions.fetches);
@@ -505,6 +492,7 @@ function createNodeProviderAdapter(baseOptions = {}) {
505
492
  }
506
493
 
507
494
  return Object.freeze({
495
+ prepareConditionalKv(effect, execution) { return kvReferenceFor(execution).prepareConditionalKv(effect, execution); },
508
496
  id: 'node',
509
497
  version: CANONICAL_NODE_RUNTIME_VERSION,
510
498
  createCapabilities() { return {}; },
@@ -513,6 +501,9 @@ function createNodeProviderAdapter(baseOptions = {}) {
513
501
  },
514
502
  dispatchFetch,
515
503
  async dispatchEffect(effect, executionOptions = {}) {
504
+ if (['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) {
505
+ return require('./s3-reader.js').readS3(effect, { ...baseOptions, ...executionOptions }, (name) => bindingValue(executionOptions, 'secrets', name));
506
+ }
516
507
  if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
517
508
  if (effect.kind === 'jwt.verify') return verifyNativeJwt(effect, executionOptions);
518
509
  if (effect.kind === 'grip.broadcast') {
@@ -543,28 +534,9 @@ function createNodeProviderAdapter(baseOptions = {}) {
543
534
  );
544
535
  }
545
536
  if (effect.kind === 'kv.get' || effect.kind === 'kv.put') {
546
- const limits = bindingOptions(executionOptions);
547
- const namespace = portableRuntimeHost.normalizeKvNamespace(effect.store, limits);
548
- const key = portableRuntimeHost.normalizeKvKey(effect.key, limits);
549
- const stores = kvStoresFor(executionOptions);
550
- if (!stores.has(namespace)) stores.set(namespace, new Map());
551
- const store = stores.get(namespace);
552
- if (effect.kind === 'kv.get') {
553
- return portableRuntimeHost.cloneKvValue(store.get(key), {
554
- operation: 'get',
555
- allowUndefined: true,
556
- maxBytes: bindingOptions(executionOptions).maxKvValueBytes,
557
- maxDepth: bindingOptions(executionOptions).maxKvValueDepth,
558
- maxEntries: bindingOptions(executionOptions).maxKvValueEntries
559
- });
560
- }
561
- store.set(key, portableRuntimeHost.cloneKvValue(effect.value, {
562
- operation: 'put',
563
- maxBytes: bindingOptions(executionOptions).maxKvValueBytes,
564
- maxDepth: bindingOptions(executionOptions).maxKvValueDepth,
565
- maxEntries: bindingOptions(executionOptions).maxKvValueEntries
566
- }));
567
- return true;
537
+ const namespace = portableRuntimeHost.normalizeKvNamespace(effect.store, bindingOptions(executionOptions));
538
+ const store = kvReferenceFor(executionOptions).kv(namespace);
539
+ return effect.kind === 'kv.get' ? store.get(effect.key) : store.put(effect.key, effect.value);
568
540
  }
569
541
  if (effect.kind === 'event.emit') {
570
542
  const eventAdapter = executionOptions.eventAdapter || baseOptions.eventAdapter;
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('node:crypto');
4
+ const host = require('@pulse-compute/runtime/host');
5
+
6
+ // One explicit instance is one local authority. Reuse it across requests/targets
7
+ // to exercise shared storage. Identity/sequence injection is for deterministic
8
+ // host tests, never application input. This is not durable production storage.
9
+ function createNodeKvReference(options = {}) {
10
+ const identity = options.kvInstanceId === undefined ? randomUUID() : options.kvInstanceId;
11
+ if (typeof identity !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(identity)) throw new TypeError('Node KV instance identity must be bounded ASCII.');
12
+ let sequence = options.kvGenerationStart === undefined ? 0n : BigInt(options.kvGenerationStart);
13
+ if (sequence < 0n || sequence >= (1n << 128n)) throw new TypeError('Node KV generation sequence is out of range.');
14
+ const stores = new Map(), records = new Map();
15
+ const hooks = options.kvHooks || {};
16
+ function nextGeneration() {
17
+ if (sequence >= (1n << 128n) - 1n) throw new Error('Node KV generation space exhausted.');
18
+ sequence += 1n;
19
+ return `node-kv-v1:${identity}:${sequence.toString(16).padStart(32, '0')}`;
20
+ }
21
+ function namespace(name) {
22
+ host.normalizeKvNamespace(name, options);
23
+ if (!stores.has(name)) { stores.set(name, new Map()); records.set(name, new Map()); }
24
+ return { values: stores.get(name), entries: records.get(name) };
25
+ }
26
+ function put(name, key, value) {
27
+ const { entries, values } = namespace(name);
28
+ const wire = host.encodeConditionalKvValue(value, options);
29
+ const detached = host.decodeConditionalKvValue(wire, options);
30
+ const generation = nextGeneration();
31
+ entries.set(key, { wire, generation }); values.set(key, detached);
32
+ }
33
+ // Reject accessors instead of executing configuration as ambient code.
34
+ function entries(input) {
35
+ if (input === undefined || input === null) return [];
36
+ if (typeof input !== 'object' || Array.isArray(input) || ![null, Object.prototype].includes(Object.getPrototypeOf(input))) throw new TypeError('Node KV seed must be a data record.');
37
+ return Reflect.ownKeys(input).map((key) => {
38
+ const d = Object.getOwnPropertyDescriptor(input, key);
39
+ if (typeof key !== 'string' || !d.enumerable || !Object.prototype.hasOwnProperty.call(d, 'value')) throw new TypeError('Node KV seed must contain data properties.');
40
+ return [key, d.value];
41
+ });
42
+ }
43
+ for (const [name, values] of entries(options.kv)) for (const [key, value] of entries(values)) {
44
+ host.normalizeKvKey(key, options); put(name, key, value);
45
+ }
46
+ function apply(effect) {
47
+ const { entries } = namespace(effect.namespace);
48
+ const current = entries.get(effect.key);
49
+ if (effect.kind === 'kv.getVersioned') return current
50
+ ? Object.freeze({ status: 'found', generation: current.generation, value: host.decodeConditionalKvValue(current.wire, options) })
51
+ : Object.freeze({ status: 'not-found' });
52
+ if (effect.kind === 'kv.compareAndSwap' && !new RegExp(`^node-kv-v1:${identity}:[0-9a-f]{32}$`).test(effect.generation)) {
53
+ return Object.freeze({ status: 'not-stored', reason: 'invalid-generation' });
54
+ }
55
+ // No await, callback, or provider GET/PUT between predicate and mutation.
56
+ const matches = effect.kind === 'kv.insertIfAbsent' ? !current : current && current.generation === effect.generation;
57
+ if (!matches) return Object.freeze({ status: 'conflict' });
58
+ put(effect.namespace, effect.key, effect.value);
59
+ return Object.freeze({ status: 'stored' });
60
+ }
61
+ async function prepareConditionalKv(effect, execution = {}) {
62
+ if (hooks.prepare) await hooks.prepare(effect, execution);
63
+ return async () => {
64
+ if (hooks.dispatch) await hooks.dispatch(effect, execution);
65
+ if (execution.signal && execution.signal.aborted) throw new Error('Node KV operation stopped before acceptance.');
66
+ const result = apply(effect);
67
+ if (result.status === 'found' && hooks.read) return hooks.read(result, effect, execution);
68
+ if (result.status === 'stored' && hooks.afterCommit) await hooks.afterCommit(effect, execution);
69
+ return result;
70
+ };
71
+ }
72
+ return Object.freeze({
73
+ version: 'pulse.node-kv-reference.v1',
74
+ prepareConditionalKv,
75
+ // Historical Assets reads use this semantic view, never the generation table.
76
+ legacyStores() { return stores; },
77
+ kv(name) {
78
+ const { values } = namespace(name);
79
+ const conditional = (operation, key, generation, value) => {
80
+ const admitted = host.admitConditionalKv({ kind: `kv.${operation}`, namespace: name, key, generation, value }, options);
81
+ return host.executeConditionalKv(admitted, prepareConditionalKv, {}, options);
82
+ };
83
+ return Object.freeze({
84
+ get(key) { host.normalizeKvKey(key, options); return host.cloneKvValue(values.get(key), { ...options, allowUndefined: true }); },
85
+ put(key, value) { host.normalizeKvKey(key, options); put(name, key, value); return true; },
86
+ getVersioned(key) { return conditional('getVersioned', key); },
87
+ insertIfAbsent(key, value) { return conditional('insertIfAbsent', key, undefined, value); },
88
+ compareAndSwap(key, generation, value) { return conditional('compareAndSwap', key, generation, value); }
89
+ });
90
+ }
91
+ });
92
+ }
93
+ module.exports = Object.freeze({ createNodeKvReference });
@@ -6,6 +6,7 @@ const routeHandlerEffects = require('./route-handler-effects.js');
6
6
  const canonicalApiRuntime = require('./canonical-api-runtime.js');
7
7
 
8
8
  module.exports = {
9
+ ...require('./conditional-kv.js'),
9
10
  compiledWasmNodeAdapterKv,
10
11
  assetsProvider,
11
12
  routeHandlerEffects,
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+ const https = require('node:https');
3
+ const { Readable } = require('node:stream');
4
+ const { performance } = require('node:perf_hooks');
5
+ const protocol = require('@pulse-compute/s3/provider');
6
+
7
+ function originRequest(request, signal) {
8
+ return new Promise((resolve, reject) => {
9
+ const req = https.request(request.url, { method: request.method, headers: request.headers, signal, maxHeaderSize: 16384 }, (response) => {
10
+ let headers = [];
11
+ try {
12
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
13
+ // Node exposes HTTP header octets as Latin-1 strings. Interpret the
14
+ // bounded protocol metadata as UTF-8, matching the Native host ABI.
15
+ for (let i = 0; i < response.rawHeaders.length; i += 2) headers.push([response.rawHeaders[i], decoder.decode(Buffer.from(response.rawHeaders[i + 1], 'latin1'))]);
16
+ } catch { headers = null; }
17
+ resolve({ status: response.statusCode, headers, body: response, close: () => response.destroy() });
18
+ });
19
+ req.once('error', reject);
20
+ req.end(request.body);
21
+ });
22
+ }
23
+ async function fetchFixture(fetch, request, signal) {
24
+ const pending = Promise.resolve().then(() => fetch(request.url, { method: request.method, headers: request.headers, ...(request.body === undefined ? {} : { body: request.body }), redirect: 'manual', cache: 'no-store', signal }));
25
+ pending.then((response) => { if (signal.aborted && response.body) void response.body.cancel().catch(() => {}); }, () => {});
26
+ const response = await abortable(pending, signal);
27
+ const body = response.body ? Readable.fromWeb(response.body, { signal }) : null;
28
+ // A deadline can fire before iteration begins; always observe the stream error.
29
+ if (body) body.on('error', () => {});
30
+ return { status: response.status, headers: [...response.headers], body, close: () => { if (body) body.destroy(); } };
31
+ }
32
+ function abortable(promise, signal) {
33
+ return new Promise((resolve, reject) => {
34
+ const abort = () => reject(signal.reason);
35
+ signal.addEventListener('abort', abort, { once: true });
36
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
37
+ if (signal.aborted) abort();
38
+ });
39
+ }
40
+ async function readS3(effect, options, lookup) {
41
+ const operation = effect.operation, put = operation === 'putText';
42
+ const payload = effect.payload || {};
43
+ if (!['head', 'getText', 'putText'].includes(operation) || effect.kind !== `s3.${operation}` || effect.capability !== effect.kind
44
+ || effect.package !== '@pulse-compute/s3' || effect.contractId !== 'pulse.s3' || effect.providerKind !== 's3') throw new TypeError('Invalid S3 package effect authority.');
45
+ let dispatched = false, status;
46
+ const failure = (reason) => put ? protocol.putFailure(reason, dispatched, status) : protocol.failure(reason, status);
47
+ if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.');
48
+ const configured = typeof payload.binding === 'string' && options.s3 && Object.hasOwn(options.s3, payload.binding) && options.s3[payload.binding];
49
+ let binding;
50
+ try { binding = protocol.normalizeBinding(configured); } catch { return failure('configuration'); }
51
+ const controller = new AbortController();
52
+ const onAbort = () => controller.abort(options.signal.reason);
53
+ if (options.signal) options.signal.addEventListener('abort', onAbort, { once: true });
54
+ let timedOut = false;
55
+ const deadline = performance.now() + binding.timeoutMs;
56
+ const check = () => {
57
+ if (performance.now() >= deadline) { timedOut = true; controller.abort(); }
58
+ if (controller.signal.aborted) throw controller.signal.reason || new Error('S3 deadline expired.');
59
+ };
60
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, binding.timeoutMs);
61
+ const wait = async (value) => { const result = await abortable(Promise.resolve(value), controller.signal); check(); return result; };
62
+ let response;
63
+ try {
64
+ const key = protocol.encodeKey(payload.key);
65
+ if (key === null) return failure('invalid-key');
66
+ let body, contentType;
67
+ if (put) {
68
+ if (typeof payload.text !== 'string' || !payload.text.isWellFormed()) return failure('invalid-text');
69
+ body = protocol.bytes(payload.text);
70
+ if (body.length > binding.maxTextBytes) return failure('too-large');
71
+ try { contentType = protocol.normalizePutOptions({ contentType: payload.contentType }).contentType; }
72
+ catch { return failure('configuration'); }
73
+ }
74
+ let accessId, secret, token;
75
+ try {
76
+ accessId = await wait(lookup(binding.accessKeyIdSecret));
77
+ secret = await wait(lookup(binding.secretAccessKeySecret));
78
+ token = binding.sessionTokenSecret === undefined ? undefined : await wait(lookup(binding.sessionTokenSecret));
79
+ } catch (error) { check(); return failure('credentials'); }
80
+ if (!protocol.credentialsValid(accessId, secret, token)) return failure('credentials');
81
+ for (const value of [accessId, secret, token]) if (typeof options.registerRedactionValue === 'function' && typeof value === 'string') options.registerRedactionValue(value);
82
+ const crypto = options.cryptoVerifier && options.cryptoVerifier.bytes;
83
+ const algorithms = options.cryptoRealization && options.cryptoRealization.algorithms;
84
+ const realization = options.cryptoTarget === 'javascript' ? 'runtime-builtin' : 'guest-source:pulse-hmac-as';
85
+ if (!crypto || !Array.isArray(algorithms) || ['SHA-256', 'HMAC-SHA256'].some((algorithm) => !algorithms.some((entry) => entry.algorithm === algorithm && entry.available && entry.realization === realization))) return failure('configuration');
86
+ let request;
87
+ try {
88
+ request = await wait(protocol.signRequest({ binding, method: put ? 'PUT' : operation === 'head' ? 'HEAD' : 'GET', encodedKey: key, accessId, secret, token, now: Date.now(), body, contentType }, crypto));
89
+ } catch (error) { check(); return failure('configuration'); }
90
+ check();
91
+ // The send primitive may have side effects even if it throws synchronously.
92
+ dispatched = true;
93
+ response = options.fetchImplementation
94
+ ? await fetchFixture(options.fetchImplementation, request, controller.signal)
95
+ : await originRequest(request, controller.signal);
96
+ status = response.status;
97
+ check();
98
+ const rejected = put ? protocol.putStatusResult(status) : protocol.statusResult(status);
99
+ if (rejected && (!put || rejected.status === 'unknown')) return rejected;
100
+ const metadata = protocol.metadataFromHeaders(response.headers, operation === 'head');
101
+ if (!metadata) return failure('protocol');
102
+ if (operation === 'head') return protocol.normalizeResult(operation, { status: 'found', ...metadata });
103
+ const maximum = put ? rejected ? binding.maxTextBytes : 0 : binding.maxTextBytes;
104
+ if (metadata.byteLength > maximum) return failure(put ? 'protocol' : 'too-large');
105
+ const chunks = []; let total = 0;
106
+ if (response.body) for await (const chunk of response.body) {
107
+ check(); total += chunk.length;
108
+ if (total > maximum) return failure(put ? 'protocol' : 'too-large');
109
+ if (!put) chunks.push(Buffer.from(chunk));
110
+ }
111
+ check();
112
+ if (metadata.byteLength !== undefined && metadata.byteLength !== total) return failure('protocol');
113
+ if (put) return rejected || protocol.normalizeResult(operation, { status: 'stored', byteLength: body.length, sha256: request.headers['x-amz-content-sha256'], ...(metadata.etag === undefined ? {} : { etag: metadata.etag }) });
114
+ const raw = Buffer.concat(chunks, total);
115
+ const digest = protocol.hex(await wait(crypto.sha256(raw)));
116
+ let text;
117
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); }
118
+ catch { return failure('invalid-utf8'); }
119
+ check();
120
+ return protocol.normalizeResult(operation, { status: 'found', ...metadata, byteLength: total, text, sha256: digest });
121
+ } catch (error) {
122
+ if (options.signal && options.signal.aborted) throw options.signal.reason || error;
123
+ return failure(timedOut ? 'timeout' : status === undefined ? 'transport' : 'protocol');
124
+ } finally {
125
+ clearTimeout(timer);
126
+ if (options.signal) options.signal.removeEventListener('abort', onAbort);
127
+ controller.abort();
128
+ if (response) { try { await response.close(); } catch {} }
129
+ }
130
+ }
131
+ module.exports = { readS3, originRequest };
package/src/toolchain.js CHANGED
@@ -32,8 +32,11 @@ const reportingTargetCapability = Object.freeze({
32
32
  levels: Object.freeze(['off', 'error', 'warn', 'info', 'debug'])
33
33
  });
34
34
 
35
- function genericProviderConfig() {
36
- return Object.freeze({ kind: 'node', bindings: Object.freeze({}), local: Object.freeze({}) });
35
+ function genericProviderConfig(value) {
36
+ const input = value && typeof value === 'object' ? value : {};
37
+ const bindings = input.bindings || {};
38
+ if (Object.keys(bindings).some((key) => key !== 's3')) throw new TypeError('Unknown Node binding field.');
39
+ return Object.freeze({ kind: 'node', bindings: Object.freeze({ s3: require('./config/s3.js').normalizeNodeS3(bindings.s3) }), local: Object.freeze({}) });
37
40
  }
38
41
 
39
42
  function nodeRealization(nativeArtifact) {
@@ -139,14 +142,21 @@ function createDriver() {
139
142
  sourceOnlySupported: false,
140
143
  targetSupport: Object.freeze({ javascript: NODE_JAVASCRIPT_TARGET_SUPPORT_DECLARATION }),
141
144
  normalizeConfig: genericProviderConfig,
145
+ configReference: Object.freeze({
146
+ sections: [{ id: 'node', title: 'Node provider options', description: 'Provider-owned Node profile configuration.' }],
147
+ fields: [{ section: 'node', path: 'node.bindings.s3', type: 'Readonly<Record<string, S3Binding>>', default: '`{}`', scope: 'Node Native and JavaScript S3',
148
+ description: 'Maps literal logical names to fixed HTTPS endpoint, bucket, region, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–32768, default 32768) and timeoutMs (1–30000, default 10000).',
149
+ security: 'Only named credential references are configuration. Runtime keys cannot override authority.' }]
150
+ }),
142
151
  defaultLocalNetworkFetch: true,
143
- projectConfigDocument() { return undefined; },
152
+ projectConfigDocument(config) { return Object.keys(config.bindings.s3).length ? config : undefined; },
144
153
  initTemplate() {
145
154
  return Object.freeze({ profileFragment: '', dependencies: Object.freeze({}) });
146
155
  },
147
- executionOptions(_config, values) {
156
+ executionOptions(config, values) {
148
157
  return Object.freeze({
149
158
  ...values,
159
+ s3: config.bindings.s3,
150
160
  providerAdapter: nodeRuntime.createNodeProviderAdapter()
151
161
  });
152
162
  },
@@ -205,7 +215,8 @@ function createDriver() {
205
215
  javascript: NODE_JAVASCRIPT_TARGET_DESCRIPTOR
206
216
  }),
207
217
  execute: nodeRuntime.executeCanonicalProgram,
208
- createLoweringPlan(metadata) {
218
+ createLoweringPlan(metadata, config = {}) {
219
+ require('./config/s3.js').validateNodeS3Operations(metadata, config.bindings || {});
209
220
  return canonicalProvider.createProviderLoweringPlan(metadata, nodeRuntime.NODE_PROVIDER_DESCRIPTOR, {
210
221
  grip: 'node-reference-broadcaster'
211
222
  });