@pulse-compute/provider-fastly 1.0.0-beta.10 → 1.0.0-beta.11

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.
Files changed (36) hide show
  1. package/README.md +6 -6
  2. package/package.json +11 -11
  3. package/src/build/canonical-target.js +6 -0
  4. package/src/build/effect-invocations.js +6 -4
  5. package/src/build/host-extensions.js +254 -0
  6. package/src/build/lifecycle.js +50 -0
  7. package/src/build/native-body-forwarding.as.ts +28 -44
  8. package/src/build/native-body-forwarding.js +15 -11
  9. package/src/build/native-fetch-accounting.as.ts +133 -0
  10. package/src/build/native-fetch-accounting.js +84 -0
  11. package/src/build/native-fetch-bounds.js +47 -0
  12. package/src/build/native-fetch-cohorts.as.ts +126 -0
  13. package/src/build/native-fetch-cohorts.js +65 -0
  14. package/src/build/native-fetch-deadlines.as.ts +13 -8
  15. package/src/build/native-fetch-deadlines.js +13 -4
  16. package/src/build/native-fetch-errors.js +2 -2
  17. package/src/build/native-fetch-headers.js +52 -0
  18. package/src/build/native-fetch-outcomes.js +87 -0
  19. package/src/build/native-fetch-timing.as.ts +74 -0
  20. package/src/build/native-fetch-timing.js +50 -0
  21. package/src/build/native-forwarding-accounting.as.ts +153 -0
  22. package/src/build/native-forwarding-accounting.js +47 -0
  23. package/src/build/native-header-lookup.js +2 -2
  24. package/src/build/native-http-effects.js +10 -4
  25. package/src/build/native-platform-capabilities.js +89 -39
  26. package/src/build/native-request-body.js +6 -5
  27. package/src/build/{request-budget.as.ts → request-budget-source.js} +12 -4
  28. package/src/build/request-budget.js +7 -8
  29. package/src/build/response-authority.js +56 -0
  30. package/src/javascript/fetch-adapter.js +3 -0
  31. package/src/javascript/runtime-host.js +1 -0
  32. package/src/provider-contract.js +2 -1
  33. package/src/testing/native-http-effects-host.js +9 -0
  34. package/src/testing/native-platform-capabilities-host.js +10 -0
  35. package/src/toolchain/index.js +2 -1
  36. package/src/toolchain/native-execution.js +7 -2
package/README.md CHANGED
@@ -7,9 +7,9 @@
7
7
  > **Supported entry points:** `@pulse-compute/provider-fastly`, `@pulse-compute/provider-fastly/contract`, `@pulse-compute/provider-fastly/toolchain`, `@pulse-compute/provider-fastly/config-schema`, `@pulse-compute/provider-fastly/runtime/canonical-api-runtime`, `@pulse-compute/provider-fastly/build/canonical-target`, `@pulse-compute/provider-fastly/testing/fastly-cli`<br>
8
8
  > **Stability:** The listed entry points are supported for the Beta; all other exported subpaths are implementation-only.<br>
9
9
  > **npm:** [`@pulse-compute/provider-fastly`](https://www.npmjs.com/package/@pulse-compute/provider-fastly)<br>
10
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.10/packages/provider-fastly/)
10
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.11/packages/provider-fastly/)
11
11
  >
12
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.10` package policy.
12
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.11` package policy.
13
13
  <!-- pulse-package-status:end -->
14
14
 
15
15
  Fastly provider configuration, canonical capability lowering, local conformance execution, Compute target generation, and explicit external-tool boundaries.
@@ -49,7 +49,7 @@ trusted wall-clock authority. It does not fall back to Node execution.
49
49
 
50
50
  ## Documentation
51
51
 
52
- - [Fastly package guide](https://pulsecompute.io/v1.0.0-beta.10/packages/provider-fastly/)
53
- - [Project configuration](https://pulsecompute.io/v1.0.0-beta.10/reference/project-config/#fastly-provider-options)
54
- - [Fastly capabilities guide](https://pulsecompute.io/v1.0.0-beta.10/guides/fastly-capabilities/)
55
- - [Environment variables](https://pulsecompute.io/v1.0.0-beta.10/reference/environment/#pulse-fastly-bin)
52
+ - [Fastly package guide](https://pulsecompute.io/v1.0.0-beta.11/packages/provider-fastly/)
53
+ - [Project configuration](https://pulsecompute.io/v1.0.0-beta.11/reference/project-config/#fastly-provider-options)
54
+ - [Fastly capabilities guide](https://pulsecompute.io/v1.0.0-beta.11/guides/fastly-capabilities/)
55
+ - [Environment variables](https://pulsecompute.io/v1.0.0-beta.11/reference/environment/#pulse-fastly-bin)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/provider-fastly",
3
- "version": "1.0.0-beta.10",
3
+ "version": "1.0.0-beta.11",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": "^22.14.0 || ^24.0.0"
@@ -59,15 +59,15 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@pulse-compute/crypto": "1.0.0-beta.10",
63
- "@pulse-compute/jwt": "1.0.0-beta.10",
64
- "@pulse-compute/runtime": "1.0.0-beta.10",
65
- "@pulse-compute/s3": "1.0.0-beta.10",
66
- "@pulse-compute/wasm-build-support": "1.0.0-beta.10",
67
- "@pulse-compute/wasm-contracts": "1.0.0-beta.10",
68
- "@pulse-compute/wasm-guest-link": "1.0.0-beta.10",
69
- "@pulse-compute/wasm-host-runtime": "1.0.0-beta.10",
70
- "@pulse-compute/wasm-runtime-core-as": "1.0.0-beta.10",
62
+ "@pulse-compute/crypto": "1.0.0-beta.11",
63
+ "@pulse-compute/jwt": "1.0.0-beta.11",
64
+ "@pulse-compute/runtime": "1.0.0-beta.11",
65
+ "@pulse-compute/s3": "1.0.0-beta.11",
66
+ "@pulse-compute/wasm-build-support": "1.0.0-beta.11",
67
+ "@pulse-compute/wasm-contracts": "1.0.0-beta.11",
68
+ "@pulse-compute/wasm-guest-link": "1.0.0-beta.11",
69
+ "@pulse-compute/wasm-host-runtime": "1.0.0-beta.11",
70
+ "@pulse-compute/wasm-runtime-core-as": "1.0.0-beta.11",
71
71
  "esbuild": "0.28.1"
72
72
  },
73
73
  "types": "./dist/index.d.ts",
@@ -87,7 +87,7 @@
87
87
  "url": "git+https://github.com/pulse-compute/pulse.git",
88
88
  "directory": "packages/provider-fastly"
89
89
  },
90
- "homepage": "https://pulsecompute.io/v1.0.0-beta.10/packages/provider-fastly/",
90
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.11/packages/provider-fastly/",
91
91
  "bugs": {
92
92
  "url": "https://github.com/pulse-compute/pulse/issues"
93
93
  },
@@ -82,6 +82,12 @@ function assertExactProviderNative(native) {
82
82
  }
83
83
  );
84
84
  }
85
+ if (native.manifest.hostExtensions) {
86
+ const evidence = native.manifest.hostExtensions;
87
+ if (evidence.finalWasmSha256 !== observedSha256) throw providerBuildError(
88
+ 'PULSE_HOST_EXTENSION_IMPORT', 'Terminal service evidence does not identify the packaged Wasm.', { automaticFallback: false });
89
+ require('@pulse-compute/wasm-contracts/provider/toolchain').hostExtensions.auditWasm(native.wasm, evidence.importPolicy);
90
+ }
85
91
  if (native.guestLink) {
86
92
  const audit = native.guestLink.audit;
87
93
  if (
@@ -3,6 +3,8 @@
3
3
  const { CANONICAL_EFFECT_INVOCATION_POLICY } = require('@pulse-compute/wasm-contracts/handler/canonical-runtime');
4
4
 
5
5
  function runtimeSource(options = {}) {
6
+ const lifecycle = options.lifecycle;
7
+ const delivery = options.packageCompletion ? 'result == 0 ? pulse_package_set_effect_failure(index) : pulse_set_effect_result(index, result)' : 'pulse_set_effect_result(index, result)';
6
8
  return `
7
9
  const PULSE_FASTLY_MAX_EFFECT_INVOCATIONS: i32 = ${CANONICAL_EFFECT_INVOCATION_POLICY.defaultMaxEffects}
8
10
  let __pulse_invocation_count: i32 = 0
@@ -28,13 +30,13 @@ function __pulse_invocation_settle(index: i32, ticket: i32, result: i32): i32 {
28
30
  || ticket <= 0 || unchecked(__pulse_invocation_tickets[index]) != ticket) {
29
31
  __pulse_fastly_fail(PULSE_ERROR_STATE, __pulse_invocation_failure, index); return 0
30
32
  }
31
- unchecked(__pulse_invocation_tickets[index] = 0)
32
- return ${options.packageCompletion ? "result == 0 ? pulse_package_set_effect_failure(index) : pulse_set_effect_result(index, result)" : "pulse_set_effect_result(index, result)"}
33
+ ${lifecycle?.site('effect-settle') ? lifecycle.site('effect-settle') + '\n' : ''} unchecked(__pulse_invocation_tickets[index] = 0)
34
+ ${lifecycle ? lifecycle.site('effect-deliver', delivery) : 'return ' + delivery}
33
35
  }
34
36
  function __pulse_invocation_close(): void {
35
- __pulse_invocation_closed = true
37
+ ${lifecycle?.site('invocation-close') ? lifecycle.site('invocation-close') + '\n' : ''} __pulse_invocation_closed = true
36
38
  for (let i = 0; i < PULSE_FASTLY_EFFECT_COUNT; i += 1) unchecked(__pulse_invocation_tickets[i] = 0)
37
- }
39
+ ${lifecycle?.site('invocation-closed') ? lifecycle.site('invocation-closed') + '\n' : ''}}
38
40
  `;
39
41
  }
40
42
 
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ // The provider owns the terminal service. Package selection and ABI admission
4
+ // use the shared contribution contract; trusted source is not sandboxed.
5
+ const contract = require('@pulse-compute/wasm-contracts/provider/toolchain').hostExtensions;
6
+ const { CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES } = require('@pulse-compute/wasm-contracts/handler/canonical-native-runtime');
7
+ const MAX_BACKEND_BYTES = Math.min(1024, CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES);
8
+ const { STATES } = require('./response-authority.js');
9
+ const ADMISSION = contract.normalizeAdmission({ version: contract.ADMISSION_VERSION,
10
+ protocols: [contract.VERSION], services: [contract.TERMINAL_SERVICE] });
11
+ const TARGET = Object.freeze({ provider: 'fastly', target: 'native', targetId: 'fastly-compute-native', hostExtensions: ADMISSION });
12
+ function fail(message, operation) { contract.fail('PLACEMENT', message, operation); }
13
+ const matches = contract.matches;
14
+ function validateBinding(effect, bindings = {}) {
15
+ const key = effect.resource?.value;
16
+ const backend = effect.resource?.kind === 'literal' && (bindings.backends || {})[key];
17
+ if (typeof key !== 'string' || typeof backend !== 'string' || !backend || backend.includes('\0') || Buffer.byteLength(backend) > MAX_BACKEND_BYTES)
18
+ contract.fail('BINDING', `${effect.package}.${effect.operation} requires a configured static backend binding.`, effect);
19
+ return backend;
20
+ }
21
+ function select(plan, options = {}) {
22
+ try { return selectPlan(plan, options); }
23
+ catch (error) {
24
+ if (error.code?.startsWith('PULSE_HOST_EXTENSION_') && plan.entry?.kind === 'router') {
25
+ const used = (plan.effects || []).filter(effect => effect.kind === 'host.extension');
26
+ const effect = used.find(effect => effect.package === error.detail?.package && effect.operation === error.detail?.operation) || used[0];
27
+ const entry = plan.routing?.entries?.find(entry => entry.stableId === effect?.routerEntryStableId);
28
+ const source = entry?.nativeBody?.source || plan.handlers?.find(handler => handler.id === effect?.routerEntryStableId)?.source || null;
29
+ // Lowered Router positions address generated dispatch text. Public errors
30
+ // must identify the authored handler and state the coarser source basis.
31
+ error.detail = Object.freeze({ ...error.detail, loc: source, sourceBasis: source ? 'authored-entry' : 'not-recorded' });
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ function selectPlan(plan, options = {}) {
37
+ const effects = plan.effects || [];
38
+ const used = effects.filter(effect => effect.kind === 'host.extension');
39
+ if (!used.length) return [];
40
+ const reject = message => fail(message, used[0]);
41
+ // Reuse the canonical Router dispatcher and private terminal-route bodies.
42
+ // Admission bounds authored behavior; no new scheduler or package special case.
43
+ const routed = plan.entry?.kind === 'router';
44
+ if (used.length !== 1 || effects.length !== 1 || used[0].grouped || used[0].result?.mode !== 'return'
45
+ || used[0].declaredResult !== 'host-terminal'
46
+ || (plan.helpers || []).length || (plan.callables || []).length || (plan.stages || []).length
47
+ || options.bodyForwarding || options.bindings?.dynamicBackends || options.guestUnits?.length
48
+ || plan.packages?.application || plan.logging?.enabledStatements)
49
+ reject('only one direct terminal effect without surrounding effects or composition is admitted.');
50
+ const handlers = new Map((plan.handlers || []).map(handler => [handler.id, handler]));
51
+ const entries = plan.routing?.entries || [];
52
+ if (routed) {
53
+ if (!entries.length || entries.length !== handlers.size
54
+ || entries.some(entry => entry.kind !== 'route' || entry.nativeBody?.family !== 'terminal-route'
55
+ || !handlers.has(entry.stableId))
56
+ || used[0].routerEntryKind !== 'route' || !handlers.has(used[0].routerEntryStableId)
57
+ || !used[0].statementPath?.startsWith(`handlers.${used[0].routerEntryStableId}.body[`))
58
+ reject('terminal Router placement requires private route bodies without middleware or error composition.');
59
+ } else if (plan.entry?.kind !== 'handler' || handlers.size || entries.length
60
+ || !used[0].statementPath?.startsWith('entry.body[')) {
61
+ reject('terminal placement requires a plain HTTP handler or one selected Router route.');
62
+ }
63
+ const dispatcherLocals = new Set((plan.locals || []).filter(local => local.scopeId === 'entry').map(local => local.id));
64
+ function expression(value, dispatcher = false) {
65
+ if (!value || typeof value !== 'object') return;
66
+ if (dispatcher && value.kind === 'local' && dispatcherLocals.has(value.id)) return;
67
+ if (dispatcher && value.kind === 'undefined') return;
68
+ if (dispatcher && value.kind === 'assignment' && value.operator === '='
69
+ && value.target?.kind === 'local' && dispatcherLocals.has(value.target.id)) {
70
+ expression(value.value, true); return;
71
+ }
72
+ // Router exhaustion emits a literal status option; authored text siblings
73
+ // may use that same bounded response shape, without evaluating callbacks.
74
+ if (routed && value.kind === 'object' && value.entries?.every(entry => entry.kind === 'property'
75
+ && entry.key?.kind === 'literal' && entry.key.value === 'status' && entry.value?.kind === 'literal'
76
+ && Number.isInteger(entry.value.value) && entry.value.value >= 100 && entry.value.value <= 599)) return;
77
+ if (!['literal', 'context-read', 'binary', 'intrinsic'].includes(value.kind)) reject('unsupported expression in the terminal proof.');
78
+ if (value.kind === 'context-read' && !['req.path', 'req.method', 'req.url'].includes(value.path.join('.'))) reject('request mutation/body/state is outside this proof.');
79
+ if (value.kind === 'intrinsic' && value.name !== 'response.text' && !(dispatcher && value.name === 'router.match'))
80
+ reject('only a plain text response control is admitted beside the handoff.');
81
+ for (const child of [value.left, value.right, ...(value.arguments || [])]) if (child) expression(child, dispatcher);
82
+ }
83
+ let occurrences = 0;
84
+ const calls = new Map();
85
+ function statements(body, owner) {
86
+ const dispatcher = routed && owner === 'entry';
87
+ for (const [index, statement] of body.entries()) {
88
+ if (statement.kind === 'if') { expression(statement.test, dispatcher); statements(statement.then, owner); statements(statement.else, owner); }
89
+ else if (statement.kind === 'return') {
90
+ if (statement.value?.kind !== 'intrinsic' || statement.value.name !== 'response.text') reject('Ordinary terminal branches must return a text response.');
91
+ expression(statement.value, dispatcher);
92
+ } else if (statement.kind === 'effect') {
93
+ if (dispatcher || statement.effectId !== used[0].id || statement.result?.mode !== 'return'
94
+ || (routed && owner !== used[0].routerEntryStableId)) reject('handoff must return directly from its selected route.');
95
+ occurrences++;
96
+ } else if (dispatcher && statement.kind === 'handler-call' && handlers.has(statement.handlerId)) {
97
+ calls.set(statement.handlerId, (calls.get(statement.handlerId) || 0) + 1);
98
+ } else if (dispatcher && statement.kind === 'local' && dispatcherLocals.has(statement.localId)) {
99
+ expression(statement.value, true);
100
+ } else if (dispatcher && statement.kind === 'expression') {
101
+ expression(statement.expression, true);
102
+ } else if (routed && !dispatcher && index === 0 && statement.kind === 'local'
103
+ && statement.name === `__pulse_router_transferred_${entries.find(entry => entry.stableId === owner)?.index}`
104
+ && handlers.get(owner)?.localIds.includes(statement.localId)
105
+ && statement.value?.kind === 'literal' && statement.value.value === false) {
106
+ // The canonical route body's private transfer flag, not an authored token.
107
+ } else reject('only bounded route dispatch, pure conditions, direct handoff and text responses are admitted.');
108
+ }
109
+ }
110
+ statements(plan.entry.body, 'entry');
111
+ for (const handler of handlers.values()) {
112
+ if (calls.get(handler.id) !== 1) reject('each terminal route requires exactly one dispatcher call.');
113
+ statements(handler.body, handler.id);
114
+ }
115
+ if (occurrences !== 1) reject('exactly one terminal handoff site is required.');
116
+ const effect = used[0];
117
+ const inputs = effect.inputs || [];
118
+ if (inputs.length !== 2 || inputs[0].name !== 'resource' || inputs[1].name !== 'payload'
119
+ || inputs[1].value?.kind !== 'object' || inputs[1].value.entries?.length !== 0
120
+ || inputs[0].value?.kind !== 'object' || inputs[0].value.entries?.length !== 2
121
+ || inputs[0].value.entries.some(entry => entry.kind !== 'property' || entry.key?.kind !== 'literal'
122
+ || !['kind', 'value'].includes(entry.key.value) || entry.value?.kind !== 'literal'
123
+ || entry.value.value !== effect.resource?.[entry.key.value])) reject('The terminal service accepts only a static resource and empty payload.');
124
+ const target = options.targetDescriptor ? { provider: 'fastly', ...options.targetDescriptor } : TARGET;
125
+ const [artifact] = contract.select(used, options.realizationArtifacts || [], target);
126
+ const data = artifact.data;
127
+ if (data.service !== contract.TERMINAL_SERVICE || data.imports.length !== 1
128
+ || JSON.stringify(data.imports[0].parameters) !== '["i32","i32","i32"]'
129
+ || JSON.stringify(data.imports[0].results) !== '["i32"]')
130
+ contract.fail('SIGNATURE', 'The terminal backend service requires (i32, i32, i32) -> i32.', effect);
131
+ const backend = validateBinding(effect, options.bindings || options);
132
+ return Object.freeze([Object.freeze({ artifact, effect, index: effects.indexOf(effect), backend })]);
133
+ }
134
+ // The terminal service uses driver-owned response authority and binds each
135
+ // selected contribution to a slot: static per-effect data (effect index, ABI
136
+ // entry, backend). Only one terminal handoff can win per request, so the
137
+ // dynamic state (dispatching slot, token, ticket, settlement) is request-scoped.
138
+ function slotSwitch(selected, slot, cases, fallback) {
139
+ return selected.map((item, n) => `if (${slot} == ${n}) ${cases(item, n)}`).join('\n ') + `\n ${fallback}`;
140
+ }
141
+ function source(selected) {
142
+ if (!selected.length) return '';
143
+ const contributed = [...new Map(selected.map(item => [item.artifact.id, item.artifact.data.source])).values()].join('\n');
144
+ return `
145
+ ${contributed}
146
+ let __pulse_extension_status: i32 = -1
147
+ let __pulse_extension_slot: i32 = -1
148
+ let __pulse_extension_token: i32 = 0
149
+ let __pulse_extension_invocation: i32 = 0
150
+ let __pulse_extension_delivering: bool = false
151
+ let __pulse_extension_settled: bool = false
152
+ let __pulse_extension_handles_owned: bool = false
153
+ function __pulse_extension_slot_of(effectIndex: i32): i32 {
154
+ ${selected.map((item, n) => `if (effectIndex == ${item.index}) return ${n}`).join('\n ')}
155
+ return -1
156
+ }
157
+ function __pulse_extension_backend(slot: i32): ArrayBuffer {
158
+ ${slotSwitch(selected, 'slot', item => `return __pulse_fastly_utf8(${JSON.stringify(item.backend)})`, 'return new ArrayBuffer(0)')}
159
+ }
160
+ function __pulse_extension_call(slot: i32, request: i32, backend: ArrayBuffer): i32 {
161
+ ${slotSwitch(selected, 'slot', item => `return ${item.artifact.data.entry}(request, changetype<usize>(backend), backend.byteLength)`, 'return -1')}
162
+ }
163
+ function __pulse_extension_cleanup(): void {
164
+ // This service borrows the request handle. Status zero transfers downstream
165
+ // response authority, not ownership of these local request/body handles.
166
+ // Nonzero/trapped dispatch remains uncertain and is left to host teardown.
167
+ if (!__pulse_extension_handles_owned || (__pulse_extension_slot >= 0 && __pulse_response_authority == ${STATES['outcome-unknown']})) return
168
+ __pulse_extension_handles_owned = false
169
+ const request = __pulse_fastly_request_handle, body = __pulse_fastly_request_body_handle
170
+ __pulse_fastly_request_handle = -1
171
+ __pulse_fastly_request_body_handle = -1
172
+ const requestStatus = __pulse_fetch_close_request(request)
173
+ const bodyStatus = __pulse_fetch_close_body(body)
174
+ if (requestStatus != 0 || bodyStatus != 0) __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 285, __pulse_extension_slot)
175
+ }
176
+ function __pulse_extension_begin(effectIndex: i32, payload: __PulseFastlyValue): void {
177
+ const slot = __pulse_extension_slot_of(effectIndex)
178
+ if (slot < 0 || __pulse_response_authority != ${STATES['owned']} || !__pulse_invocation_started || __pulse_invocation_closed || __pulse_fastly_last_error != 0 || unchecked(__pulse_invocation_tickets[effectIndex]) <= 0) { __pulse_fastly_fail(PULSE_ERROR_STATE, 280, effectIndex); return }
179
+ // One bounded backend encoding and one opaque token per admitted invocation.
180
+ const backend = __pulse_extension_backend(slot)
181
+ if (backend.byteLength > ${MAX_BACKEND_BYTES} || __pulse_extension_token != 0) { __pulse_fastly_fail(PULSE_ERROR_VALUE, 280, effectIndex); return }
182
+ // Allocate before dispatch, while we still own downstream response authority.
183
+ const value = new __PulseFastlyValue()
184
+ value.kind = 9 // private provider completion token, never an authored value
185
+ __pulse_extension_token = __pulse_fastly_put(value)
186
+ if (__pulse_extension_token <= 0 || __pulse_fastly_last_error != 0) return
187
+ // Recheck the shared request budget immediately before crossing the ABI.
188
+ if (!__pulse_fetch_request_check(effectIndex) || __pulse_invocation_closed || __pulse_fastly_last_error != 0) return
189
+ __pulse_extension_invocation = unchecked(__pulse_invocation_tickets[effectIndex])
190
+ __pulse_extension_slot = slot
191
+ if (!__pulse_response_begin()) return
192
+ __pulse_extension_status = __pulse_extension_call(slot, __pulse_fastly_request_handle, backend)
193
+ if (__pulse_extension_status != 0) { __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 281, effectIndex); return }
194
+ __pulse_response_transferred()
195
+ __pulse_extension_cleanup()
196
+ if (!__pulse_fetch_request_check(effectIndex) || __pulse_invocation_closed || __pulse_fastly_last_error != 0) return
197
+ __pulse_fastly_ready_effect(effectIndex, __pulse_extension_token)
198
+ }
199
+ `;
200
+ }
201
+ // Typed contributions to the existing structural lifecycle emitter.
202
+ // Bodies see parameters and module globals, never the caller's local variables.
203
+ const OWNER = 'host-extension.terminal';
204
+ function hooks(selected) {
205
+ if (!selected.length) return [];
206
+ const mine = (effect) => `__pulse_extension_slot_of(${effect}) >= 0`;
207
+ // The dispatched slot only; an undispatched terminal effect can never settle.
208
+ const dispatched = (effect) => `__pulse_extension_slot_of(${effect}) == __pulse_extension_slot`;
209
+ return [
210
+ { point: 'request.acquired', owner: OWNER, order: 0, body: ' __pulse_extension_handles_owned = true' },
211
+ // Authenticate the terminal value before the ordinary invocation consumes its
212
+ // ticket. A raw exported setter is not authority to complete this operation.
213
+ { point: 'effect.settle', owner: OWNER, order: 0, body: ` if (!(${mine('effect')})) return true
214
+ if (!(${dispatched('effect')}) || __pulse_response_authority != ${STATES['transferred']} || __pulse_extension_settled || ticket != __pulse_extension_invocation || result != __pulse_extension_token || result <= 0) {
215
+ __pulse_fastly_fail(PULSE_ERROR_STATE, 284, effect); return false
216
+ }
217
+ return true` },
218
+ { point: 'effect.deliver', owner: OWNER, order: 0,
219
+ before: ` __pulse_extension_delivering = ${mine('effect')}`,
220
+ after: ` __pulse_extension_delivering = false
221
+ if (${mine('effect')} && accepted == 1) __pulse_extension_settled = true` },
222
+ { point: 'effect.result', owner: OWNER, order: 0, body: ` if (!(${mine('effect')})) return true
223
+ if (!(${dispatched('effect')}) || !__pulse_extension_delivering || __pulse_response_authority != ${STATES['transferred']} || __pulse_extension_settled || result != __pulse_extension_token || __pulse_invocation_closed) {
224
+ __pulse_fastly_fail(PULSE_ERROR_STATE, 284, effect); return false
225
+ }
226
+ return true` },
227
+ { point: 'invocation.close', owner: OWNER, order: 0, body: ` __pulse_extension_cleanup()
228
+ __pulse_extension_token = 0
229
+ __pulse_extension_invocation = 0
230
+ __pulse_extension_delivering = false` },
231
+ { point: 'result.claim', owner: OWNER, order: 0, body: ` if (__pulse_response_authority == ${STATES['owned']}) return false
232
+ if (__pulse_response_authority != ${STATES['transferred']} || !__pulse_extension_settled || result != __pulse_extension_token || __pulse_extension_token <= 0) { __pulse_fastly_fail(PULSE_ERROR_STATE, 283, -1); return true }
233
+ __pulse_response_complete()
234
+ __pulse_extension_token = 0
235
+ return true` }
236
+ ];
237
+ }
238
+ function policy(selected, baseline) {
239
+ return contract.composeImportPolicy(baseline, selected.map(item => item.artifact));
240
+ }
241
+ function evidence(selected, lifecycle, importPolicy) {
242
+ if (!selected.length) return undefined;
243
+ return Object.freeze({ version: 'pulse.fastly-terminal-service.v1', service: contract.TERMINAL_SERVICE,
244
+ implementation: require('../provider-contract.js').FASTLY_PROVIDER_DESCRIPTOR.lowering['host.extension'],
245
+ contributions: importPolicy.contributions,
246
+ useSites: selected.map(({artifact,effect,index}) => ({ contributionId: artifact.id, effectId: effect.id, index,
247
+ source: effect.source || null })),
248
+ lifecycle: { executionVersion: lifecycle.plan.executionVersion, httpVersion: lifecycle.plan.httpVersion,
249
+ selectedPoints: lifecycle.plan.sites.map(site => ({ site: site.id, points: site.points.map(point => ({ point: point.point, kind: point.kind, contributions: point.contributions })) })),
250
+ coverage: 'selected-points; normal-return-only after/end/closed; no trap finalizer' },
251
+ limits: { backendBytes: MAX_BACKEND_BYTES, completionValues: 1 },
252
+ accounting: 'not-selected', importPolicy });
253
+ }
254
+ module.exports = { ADMISSION, TARGET, MAX_BACKEND_BYTES, validateBinding, select, matches, source, hooks, policy, evidence };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const { executionLifecycle, httpLifecycle, lifecyclePlanner } = require('@pulse-compute/wasm-contracts/provider/toolchain');
4
+ const { createLifecycleEmitter } = require('@pulse-compute/wasm-runtime-core-as/compiler/canonical-native');
5
+ const forwarding = require('./native-body-forwarding.js');
6
+ const authority = require('./response-authority.js');
7
+
8
+ // Provider-owned site and ABI bindings. No anchors, source scanning or insertion
9
+ // order. The shared planner owns semantic roles; the shared emitter owns AS.
10
+ function create(plan, bindings, services = []) {
11
+ if (!Array.isArray(services)) throw new TypeError('Internal lifecycle services must be a list.');
12
+ const hooks = [...(forwarding.hooks(plan)), ...services];
13
+ const needsAuthority = hooks.some(hook => ['response.error', 'response.authority', 'result.claim'].includes(hook.point));
14
+ if (needsAuthority) hooks.push(...authority.hooks());
15
+ if (plan.packages?.application && hooks.length) throw Object.assign(new Error('Lifecycle contributions require the canonical driver emitter; package-owned emitters are not yet bound.'), { code: 'PULSE_FASTLY_LIFECYCLE_HOOK_INVALID' });
16
+ const sites = [
17
+ { id: 'request-acquired', points: [{ point: 'request.acquired', parameters: {} }] },
18
+ { id: 'effect-settle', points: [{ point: 'effect.settle', parameters: { effect: 'settle.index', ticket: 'settle.ticket', result: 'settle.result' }, reject: 'reject-settle' }] },
19
+ { id: 'effect-deliver', points: [{ point: 'effect.deliver', parameters: { effect: 'settle.index', result: 'settle.result' }, afterParameters: { accepted: 'delivery.accepted' } }] },
20
+ { id: 'effect-result', points: [{ point: 'effect.result', parameters: { effect: 'setter.index', result: 'setter.result' }, reject: 'reject-result' }] },
21
+ { id: 'invocation-close', points: [{ point: 'invocation.close', parameters: {} }] },
22
+ { id: 'invocation-end', points: [{ point: 'invocation.end', parameters: {} }] },
23
+ { id: 'invocation-closed', points: [{ point: 'invocation.closed', parameters: {} }] },
24
+ { id: 'response-error', points: [
25
+ { point: 'response.authority', parameters: {}, reject: 'stop-error' },
26
+ { point: 'response.error', parameters: {}, onClaim: 'stop-error' }
27
+ ] },
28
+ { id: 'response-send', points: [{ point: 'response.authority', parameters: {}, reject: 'reject-send' }] },
29
+ ...(bindings.maxDurationMs === undefined ? [] : [{ id: 'response-timeout', points: [{ point: 'response.authority', parameters: {}, reject: 'stop-error' }] }]),
30
+ { id: 'result-claim', points: [{ point: 'result.claim', parameters: { result: 'driver.result' }, onClaim: 'stop-error' }] }
31
+ ];
32
+ const emitter = createLifecycleEmitter({
33
+ bindings: { version: lifecyclePlanner.BINDINGS_VERSION, executionVersion: executionLifecycle.VERSION, httpVersion: httpLifecycle.VERSION, sites },
34
+ contributions: hooks.map(({ point, owner, order }) => ({ point, owner, order })),
35
+ implementations: hooks.map(({ order, ...implementation }) => implementation),
36
+ values: {
37
+ 'settle.index': { name: 'index', type: 'i32' }, 'settle.ticket': { name: 'ticket', type: 'i32' }, 'settle.result': { name: 'result', type: 'i32' },
38
+ 'setter.index': { name: 'effectIndex', type: 'i32' }, 'setter.result': { name: 'handle', type: 'i32' },
39
+ 'delivery.accepted': { name: '__pulse_delivery_accepted', type: 'i32' }, 'driver.result': { name: 'result', type: 'i32' }
40
+ },
41
+ actions: { 'reject-settle': 'return 0;', 'reject-result': 'return 0;', 'stop-error': 'return;', 'reject-send': '{ __pulse_fastly_fail(PULSE_ERROR_STATE, 282, -1); return PULSE_ERROR_STATE; }' }
42
+ });
43
+ return Object.freeze({ ...emitter, authority: needsAuthority,
44
+ // Legacy error/body/deadline owners instrument only the inner implementation.
45
+ // Structural wrappers keep shared authority outside them, regardless of pass order.
46
+ legacyName: name => emitter.active ? name + '_impl' : name,
47
+ supportSource: [emitter.source, ...(needsAuthority ? [authority.runtimeSource()] : [])].filter(Boolean).join('\n')
48
+ });
49
+ }
50
+ module.exports = Object.freeze({ create });
@@ -29,7 +29,6 @@ let __fwd_index: i32 = -1
29
29
  let __fwd_result: i32 = 0
30
30
  let __fwd_marker: i32 = 0
31
31
  let __fwd_failure: i32 = 0
32
- let __fwd_committed: bool = false
33
32
  let __fwd_reserved: bool = false
34
33
  let __fwd_header_count: i32 = 0
35
34
  let __fwd_request_length: i32 = -1
@@ -42,13 +41,14 @@ function host_request_body(): i32 {
42
41
  }
43
42
  function __fwd_fail(status: i32): void {
44
43
  if (__fwd_failure == 0) __fwd_failure = status;
44
+ if (__fwd_ra !== null) __fwd_ra!.fail(1,u32(status));
45
45
  __pulse_fastly_fail(PULSE_ERROR_HOSTCALL, 280, __fwd_index);
46
46
  }
47
47
  function __fwd_remaining(): i32 {
48
48
  if (__fwd_failure != 0) return 0;
49
- if (wasi_snapshot_preview1_clock_time_get(1, 1, __fwd_io) != 0) { __fwd_fail(504); return 0; }
49
+ if (wasi_snapshot_preview1_clock_time_get(1, 1, __fwd_io) != 0) { __fra_clock_failed = true; __fwd_fail(504); return 0; }
50
50
  const now = load<i64>(__fwd_io);
51
- if (now < 0 || now < __request_last_clock || now < __pulse_fetch_last_clock) { __fwd_fail(504); return 0; }
51
+ if (now < 0 || now < __request_last_clock || now < __pulse_fetch_last_clock) { __fra_clock_failed = true; __fwd_fail(504); return 0; }
52
52
  __request_last_clock = now; __pulse_fetch_last_clock = now;
53
53
  let remaining = f64(__request_deadline - now) / 1000000.0;
54
54
  if (__request_expired || remaining <= 0) { __request_expired = true; __fwd_fail(504); return 0; }
@@ -57,35 +57,20 @@ function __fwd_remaining(): i32 {
57
57
  return i32(Math.ceil(remaining)); // 1..30000; zero never reaches async.select
58
58
  }
59
59
  function __fwd_cleanup(): void {
60
- if (__fwd_source >= 0) { __pulse_fetch_close_body(__fwd_source); __fwd_source = -1; }
61
- if (__fwd_upload >= 0) { __fwd_abandon(__fwd_upload); __fwd_upload = -1; }
62
- if (__fwd_request >= 0) { __pulse_fetch_close_request(__fwd_request); __fwd_request = -1; }
63
- if (__fwd_body >= 0) { __pulse_fetch_close_body(__fwd_body); __fwd_body = -1; }
64
- if (__fwd_response >= 0) { __pulse_fetch_close_response(__fwd_response); __fwd_response = -1; }
65
- if (__fwd_output >= 0) { __pulse_fetch_close_response(__fwd_output); __fwd_output = -1; }
66
- if (__fwd_downstream >= 0) { __fwd_abandon(__fwd_downstream); __fwd_downstream = -1; }
67
- if (__fwd_pending >= 0) {
68
- // No pending-request cancel ABI. Poll once without waiting and discard any
69
- // late handles; an unresolved host operation ends at invocation teardown.
70
- __pulse_send_error_detail_reset(__fwd_detail);
71
- store<i32>(__fwd_done, 0);
72
- const status = __pulse_fetch_poll(__fwd_pending, __fwd_detail, __fwd_done, __fwd_response_out, __fwd_body_out);
73
- if (status == 0 && load<i32>(__fwd_done) == 1) {
74
- __pulse_fetch_close_response(load<i32>(__fwd_response_out)); __pulse_fetch_close_body(load<i32>(__fwd_body_out));
75
- }
76
- __fwd_pending = -1;
77
- }
60
+ __fra_cleanup();
61
+ __fwd_source = -1; __fwd_upload = -1; __fwd_request = -1;
62
+ __fwd_body = -1; __fwd_response = -1; __fwd_output = -1;
63
+ __fwd_downstream = -1; __fwd_pending = -1;
78
64
  }
79
65
  function __fwd_error_response(): bool {
80
- if (__fwd_committed) { __fwd_cleanup(); return true; }
66
+ if (__fwd_ra === null) return false;
67
+ if (__fwd_failure == 0 && __pulse_fastly_last_error != 0) __fwd_fail(502);
68
+ if (!__pulse_response_owned()) { __fwd_cleanup(); return true; }
81
69
  if (__fwd_failure == 0) return false;
82
70
  const status = __fwd_failure;
83
- __fwd_cleanup(); __request_error_response = true;
84
- __pulse_fastly_last_error = 0;
85
- const value = new __PulseFastlyValue(); value.kind = PULSE_VALUE_RESPONSE; value.status = status;
86
- value.text = status == 413 ? 'Payload Too Large' : status == 504 ? 'Gateway Timeout' : status == 400 ? 'Bad Request' : 'Bad Gateway';
87
- __fwd_committed = true;
88
- __pulse_fastly_send_result(__pulse_fastly_put(value)); return true;
71
+ __fwd_cleanup();
72
+ __fra_error(status);
73
+ return true;
89
74
  }
90
75
  function __fwd_lower(byte: u8): u8 { return byte >= 65 && byte <= 90 ? byte + 32 : byte; }
91
76
  function __fwd_equal(ptr: usize, size: i32, text: string): bool {
@@ -136,9 +121,9 @@ function __fwd_begin(index: i32, payload: __PulseFastlyValue): void {
136
121
  if (__pulse_fastly_last_error != 0 || !__fwd_remaining() || !__fwd_declared_request()) return;
137
122
  const url = __pulse_fastly_utf8(__pulse_fastly_string(__pulse_fastly_payload_field(payload, 'url')));
138
123
  const backend = __pulse_fastly_utf8(__pulse_fastly_backend(index));
139
- if (fastly_http_req_new(__fwd_io + 52) != 0) { __fwd_fail(502); return; }
124
+ if (__fra_new(2,__fwd_io + 52) != 0) { __fwd_fail(502); return; }
140
125
  __fwd_request = load<i32>(__fwd_io + 52);
141
- if (fastly_http_body_new(__fwd_io + 52) != 0) { __fwd_fail(502); return; }
126
+ if (__fra_new(3,__fwd_io + 52) != 0) { __fwd_fail(502); return; }
142
127
  __fwd_upload = load<i32>(__fwd_io + 52);
143
128
  const method = memory.data<u8>([80,79,83,84]);
144
129
  if (fastly_http_req_method_set(__fwd_request, method, 4) != 0 || fastly_http_req_uri_set(__fwd_request, changetype<usize>(url), url.byteLength) != 0) { __fwd_fail(502); return; }
@@ -152,9 +137,8 @@ function __fwd_begin(index: i32, payload: __PulseFastlyValue): void {
152
137
  }
153
138
  if (!__fwd_remaining()) return;
154
139
  __pulse_transport_slots[index] = null;
155
- const status = __fwd_dispatch(__fwd_request, __fwd_upload, changetype<usize>(backend), backend.byteLength, __fwd_io + 52);
156
- // send may consume the request even on failure. Cleanup makes only a best-effort
157
- // close on that error path before any handle allocation/reuse can occur.
140
+ const status = __fra_dispatch(__fwd_request, __fwd_upload, changetype<usize>(backend), backend.byteLength, __fwd_io + 52);
141
+ // An ambiguous send retains its fenced request claim; cleanup never retries it.
158
142
  if (status != 0) { __pulse_transport_capture(__fwd_index, status, 'send'); __fwd_fail(502); return; }
159
143
  __fwd_request = -1; __fwd_pending = load<i32>(__fwd_io + 52);
160
144
  __pulse_fastly_pending[index] = __fwd_pending; __pulse_fastly_pending_mode[index] = PULSE_FASTLY_PENDING_ASYNC;
@@ -165,7 +149,7 @@ function __fwd_poll(): bool {
165
149
  if (__fwd_pending < 0 || !__fwd_remaining()) return __fwd_response >= 0;
166
150
  __pulse_send_error_detail_reset(__fwd_detail);
167
151
  store<i32>(__fwd_done, 0);
168
- const status = __pulse_fetch_poll(__fwd_pending, __fwd_detail, __fwd_done, __fwd_response_out, __fwd_body_out);
152
+ const status = __fra_poll();
169
153
  const done = load<i32>(__fwd_done);
170
154
  if (status != 0) { __pulse_transport_capture(__fwd_index, status, 'poll', __fwd_detail); __fwd_pending = -1; __fwd_fail(502); return false; }
171
155
  if (done != 0 && done != 1) { __fwd_fail(502); return false; }
@@ -229,11 +213,11 @@ function __fwd_copy(source: i32, sink: i32, cap: i32, declared: i32, upload: boo
229
213
  function __fwd_wait(): i32 {
230
214
  if (__fwd_failure != 0 || __fwd_pending < 0) return 0;
231
215
  const eof = __fwd_copy(__fwd_source, __fwd_upload, __FWD_REQUEST_MAX, __fwd_request_length, true);
232
- __pulse_fetch_close_body(__fwd_source); __fwd_source = -1;
216
+ __fra_dispose(1); __fwd_source = -1;
233
217
  if (eof && __fwd_remaining()) {
234
218
  const upload = __fwd_upload; __fwd_upload = -1;
235
- if (__pulse_fetch_close_body(upload) != 0) __fwd_fail(502);
236
- } else if (__fwd_upload >= 0) { __fwd_abandon(__fwd_upload); __fwd_upload = -1; }
219
+ if (__fra_dispose(3) != 0) __fwd_fail(502);
220
+ } else if (__fwd_upload >= 0) { __fra_dispose(3,true); __fwd_upload = -1; }
237
221
  while (__fwd_failure == 0 && __fwd_response < 0) {
238
222
  if (__fwd_poll()) break;
239
223
  if (__fwd_failure != 0 || !__fwd_wait_io(__fwd_pending, false)) break;
@@ -310,9 +294,9 @@ function __fwd_nominated(name: usize, length: i32): bool {
310
294
  }
311
295
  function __fwd_send(): i32 {
312
296
  if (!__fwd_response_headers()) { __fwd_error_response(); return 0; }
313
- if (fastly_http_resp_new(__fwd_io + 52) != 0) { __fwd_fail(502); __fwd_error_response(); return 0; }
297
+ if (__fra_new(7,__fwd_io + 52) != 0) { __fwd_fail(502); __fwd_error_response(); return 0; }
314
298
  __fwd_output = load<i32>(__fwd_io + 52);
315
- if (fastly_http_resp_status_set(__fwd_output, __fwd_status) != 0 || fastly_http_body_new(__fwd_io + 52) != 0) { __fwd_fail(502); __fwd_error_response(); return 0; }
299
+ if (fastly_http_resp_status_set(__fwd_output, __fwd_status) != 0 || __fra_new(8,__fwd_io + 52) != 0) { __fwd_fail(502); __fwd_error_response(); return 0; }
316
300
  __fwd_downstream = load<i32>(__fwd_io + 52);
317
301
  for (let i = 0; i < __fwd_header_count; i++) {
318
302
  const pair = __fwd_pairs + i * 16, name = load<usize>(pair), length = load<i32>(pair + 4);
@@ -321,19 +305,19 @@ function __fwd_send(): i32 {
321
305
  if (__fwd_hop(name, length) || nominated) continue;
322
306
  if (fastly_http_resp_header_append(__fwd_output, name, length, load<usize>(pair + 8), load<i32>(pair + 12)) != 0) { __fwd_fail(502); break; }
323
307
  }
324
- __pulse_fetch_close_response(__fwd_response); __fwd_response = -1;
308
+ __fra_dispose(5); __fwd_response = -1;
325
309
  if (!__fwd_remaining()) { __fwd_error_response(); return 0; }
326
310
  // Treat even an ambiguous send failure as committed: never emit a second
327
311
  // response. The writable body remains ours until explicit EOF or abandon.
328
- __fwd_committed = true;
329
312
  const output = __fwd_output; __fwd_output = -1;
330
- if (fastly_http_resp_send_downstream(output, __fwd_downstream, 1) != 0) { __pulse_fetch_close_response(output); __fwd_fail(502); __fwd_cleanup(); return 0; }
313
+ if (__fra_send(output, __fwd_downstream, 1) != 0) { __fwd_fail(502); __fwd_cleanup(); return 0; }
331
314
  const bodyless = __fwd_status == 204 || __fwd_status == 205 || __fwd_status == 304;
332
315
  const eof = __fwd_copy(__fwd_body, __fwd_downstream, bodyless ? 0 : __FWD_RESPONSE_MAX, bodyless ? 0 : __fwd_response_length, false);
333
- __pulse_fetch_close_body(__fwd_body); __fwd_body = -1;
316
+ __fra_dispose(6); __fwd_body = -1;
334
317
  if (eof && __fwd_remaining()) {
335
318
  const downstream = __fwd_downstream; __fwd_downstream = -1;
336
- if (__pulse_fetch_close_body(downstream) != 0) __fwd_fail(502);
319
+ if (__fra_dispose(8) != 0) __fwd_fail(502);
320
+ else __pulse_response_complete();
337
321
  // Success is the host-handoff boundary. The handle is gone; there is no
338
322
  // final-writer wait, delivery receipt, or post-handoff timeout guarantee.
339
323
  }
@@ -14,12 +14,13 @@ function validate(plan, options) {
14
14
  const init = effect?.inputs?.find(input => input.name === 'init')?.value;
15
15
  const body = field(init, 'body'), method = field(init, 'method'), headers = field(init, 'headers');
16
16
  if (effects.length !== 1 || effect.kind !== 'fetch' || effect.grouped
17
+ || effect.fetchErrors !== undefined || effect.fetchTiming !== undefined
17
18
  || effect.result?.mode !== 'return' || effect.result?.valueKind !== 'opaque-response'
18
19
  || effect.resource?.kind !== 'literal' || options.bindings?.dynamicBackends
19
20
  || body?.name !== 'request.body.forward-marker' || method?.kind !== 'literal' || method.value !== 'POST'
20
21
  || init.entries.some(entry => !['method', 'body', 'headers', 'timeoutMs'].includes(entry.key?.value))
21
22
  || (headers && (headers.kind !== 'object' || headers.entries.some(entry => entry.key?.kind !== 'literal' || entry.value?.kind !== 'literal' || typeof entry.value.value !== 'string')))) {
22
- throw forwardingError('Fastly Native forwarding admits one terminal opaque POST fetch with inline req.body(), a static backend, and optional literal headers/timeoutMs; groups, decoding, reuse, and dynamic backends are unsupported.');
23
+ throw forwardingError('Fastly Native forwarding admits one terminal opaque POST fetch with inline req.body(), a static backend, and optional literal headers/timeoutMs; outcome/timing options, groups, decoding, reuse, and dynamic backends are unsupported.');
23
24
  }
24
25
  const entries = headers?.entries || [];
25
26
  const hop = /^(connection|keep-alive|proxy-connection|transfer-encoding|te|trailer|upgrade|content-length|host|proxy-authenticate|proxy-authorization)$/i;
@@ -49,15 +50,6 @@ function instrument(input, plan, bindings) {
49
50
  if (!line || !line.endsWith('}')) throw new Error('Fastly forwarding function boundary drift: ' + name);
50
51
  replace(line, `function ${name}(${args}): ${result} { ${body} }`);
51
52
  }
52
- replace('function __pulse_fastly_send_result(handle: i32): i32 {',
53
- 'function __pulse_fastly_send_result(handle: i32): i32 { if (handle == __fwd_result && handle != 0) return __fwd_send();');
54
- replace('function __pulse_fastly_jwt_send_error(): void {',
55
- 'function __pulse_fastly_jwt_send_error(): void { if (__fwd_error_response()) return;');
56
- replace('function __request_send_timeout(): void {',
57
- 'function __request_send_timeout(): void { if (__fwd_committed) { __fwd_cleanup(); return; }');
58
- replace(' __pulse_fastly_run_invocation()\n', ' __pulse_fastly_run_invocation()\n __fwd_cleanup()\n');
59
- replace(' __pulse_fastly_request_body_handle = load<i32>(changetype<usize>(handles) + 4)',
60
- ' __pulse_fastly_request_body_handle = load<i32>(changetype<usize>(handles) + 4)\n __fwd_source = __pulse_fastly_request_body_handle');
61
53
  // Generic request-budget code must not wait for headers before driving upload.
62
54
  const begin = source.indexOf('function __pulse_fastly_resolve_effect(');
63
55
  const end = source.indexOf('\n}', begin);
@@ -66,4 +58,16 @@ function instrument(input, plan, bindings) {
66
58
  return source + '\n' + `const __FWD_REQUEST_MAX: i32 = ${bindings.bodyForwarding.requestMaxBytes}\nconst __FWD_RESPONSE_MAX: i32 = ${bindings.bodyForwarding.responseMaxBytes}\n`
67
59
  + fs.readFileSync(path.join(__dirname, 'native-body-forwarding.as.ts'), 'utf8');
68
60
  }
69
- module.exports = {IMPORTS, enabled, validate, instrument};
61
+ function hooks(plan) {
62
+ if (!enabled(plan)) return [];
63
+ const owner = 'body-forwarding', order = 10;
64
+ return [
65
+ { point: 'request.acquired', owner, order, body: ' __fwd_source = __pulse_fastly_request_body_handle' },
66
+ { point: 'response.error', owner, order, body: ' return __fwd_error_response()' },
67
+ { point: 'result.claim', owner, order, body: ` if (result != __fwd_result || result == 0) return false
68
+ if (__pulse_response_owned()) __fwd_send()
69
+ return true` },
70
+ { point: 'invocation.end', owner, order, body: ' __fwd_cleanup(); __fra_cleanup(true)' }
71
+ ];
72
+ }
73
+ module.exports = {IMPORTS, enabled, validate, instrument, hooks};