|
| 1 | +/* |
| 2 | +Toolbox Aid |
| 3 | +David Quesenberry |
| 4 | +06/03/2026 |
| 5 | +engineV2CustomExtensionsHookRuntime.js |
| 6 | +*/ |
| 7 | + |
| 8 | +export const ENGINE_V2_CUSTOM_EXTENSION_HOOKS = Object.freeze({ |
| 9 | + ON_PROJECT_LOAD: "onProjectLoad", |
| 10 | + ON_SCENE_START: "onSceneStart", |
| 11 | + ON_TICK: "onTick", |
| 12 | + ON_COLLISION: "onCollision", |
| 13 | + ON_TRIGGER: "onTrigger", |
| 14 | + ON_ACTION: "onAction", |
| 15 | + ON_SCENE_END: "onSceneEnd", |
| 16 | +}); |
| 17 | + |
| 18 | +export const ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST = Object.freeze(Object.values(ENGINE_V2_CUSTOM_EXTENSION_HOOKS)); |
| 19 | + |
| 20 | +export const ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS = Object.freeze({ |
| 21 | + APPROVED: "approved", |
| 22 | + UNAPPROVED: "unapproved", |
| 23 | +}); |
| 24 | + |
| 25 | +export const ENGINE_V2_CUSTOM_EXTENSION_ALLOWED_CONTEXT_KEYS = Object.freeze([ |
| 26 | + "projectId", |
| 27 | + "sceneId", |
| 28 | + "frameId", |
| 29 | + "deltaMs", |
| 30 | + "runtimeStateSummary", |
| 31 | + "collision", |
| 32 | + "trigger", |
| 33 | + "action", |
| 34 | + "metadata", |
| 35 | +]); |
| 36 | + |
| 37 | +export const ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_CAPABILITIES = Object.freeze([ |
| 38 | + "window", |
| 39 | + "document", |
| 40 | + "localStorage", |
| 41 | + "sessionStorage", |
| 42 | + "network", |
| 43 | + "filesystem", |
| 44 | + "engineInternals", |
| 45 | + "eval", |
| 46 | + "newFunction", |
| 47 | + "globalMutation", |
| 48 | +]); |
| 49 | + |
| 50 | +export const ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_FIELDS = Object.freeze([ |
| 51 | + "code", |
| 52 | + "source", |
| 53 | + "sourceCode", |
| 54 | + "script", |
| 55 | + "functionBody", |
| 56 | + "handler", |
| 57 | + "window", |
| 58 | + "document", |
| 59 | + "localStorage", |
| 60 | + "sessionStorage", |
| 61 | + "network", |
| 62 | + "filesystem", |
| 63 | + "engineInternals", |
| 64 | +]); |
| 65 | + |
| 66 | +export const ENGINE_V2_CUSTOM_EXTENSION_ERRORS = Object.freeze({ |
| 67 | + DEFINITIONS_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_DEFINITIONS_INVALID", |
| 68 | + DEFINITION_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_DEFINITION_INVALID", |
| 69 | + HOOK_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_HOOK_INVALID", |
| 70 | + MODE_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_MODE_INVALID", |
| 71 | + CONTEXT_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_CONTEXT_INVALID", |
| 72 | + CAPABILITY_FORBIDDEN: "ENGINE_V2_CUSTOM_EXTENSION_CAPABILITY_FORBIDDEN", |
| 73 | + FIELD_FORBIDDEN: "ENGINE_V2_CUSTOM_EXTENSION_FIELD_FORBIDDEN", |
| 74 | + RUNTIME_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_RUNTIME_INVALID", |
| 75 | + HOOK_NAME_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_HOOK_NAME_INVALID", |
| 76 | +}); |
| 77 | + |
| 78 | +const APPROVAL_STATUS_LIST = Object.freeze(Object.values(ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS)); |
| 79 | + |
| 80 | +export function registerEngineV2CustomExtensionHooks({ extensionDefinitions }) { |
| 81 | + const errors = []; |
| 82 | + |
| 83 | + if (!Array.isArray(extensionDefinitions)) { |
| 84 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITIONS_INVALID, "Custom Extensions hook runtime requires extensionDefinitions array.", "extensionDefinitions")); |
| 85 | + return createCustomExtensionRuntimeResult({ runtime: null, registeredHooks: [], publishEligibility: null, errors }); |
| 86 | + } |
| 87 | + |
| 88 | + extensionDefinitions.forEach((definition, index) => { |
| 89 | + validateExtensionDefinition(definition, `extensionDefinitions[${index}]`).forEach((error) => errors.push(error)); |
| 90 | + }); |
| 91 | + |
| 92 | + if (errors.length > 0) { |
| 93 | + return createCustomExtensionRuntimeResult({ runtime: null, registeredHooks: [], publishEligibility: null, errors }); |
| 94 | + } |
| 95 | + |
| 96 | + const registeredHooks = extensionDefinitions.flatMap((definition) => { |
| 97 | + const approved = definition.approvalStatus === ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS.APPROVED; |
| 98 | + |
| 99 | + return definition.hookRegistrations.map((registration) => Object.freeze({ |
| 100 | + extensionId: definition.extensionId, |
| 101 | + displayName: definition.displayName, |
| 102 | + hookName: registration.hookName, |
| 103 | + extensionMode: registration.extensionMode, |
| 104 | + allowedContextKeys: Object.freeze([...registration.allowedContextKeys]), |
| 105 | + approved, |
| 106 | + creatorPrivate: !approved, |
| 107 | + publishEligible: approved, |
| 108 | + })); |
| 109 | + }); |
| 110 | + const publishEligible = registeredHooks.every((hook) => hook.publishEligible); |
| 111 | + const runtime = Object.freeze({ |
| 112 | + registeredHooks: Object.freeze(registeredHooks), |
| 113 | + publishEligibility: Object.freeze({ |
| 114 | + eligible: publishEligible, |
| 115 | + blockedByExtensionIds: Object.freeze(registeredHooks.filter((hook) => !hook.publishEligible).map((hook) => hook.extensionId)), |
| 116 | + }), |
| 117 | + }); |
| 118 | + |
| 119 | + return createCustomExtensionRuntimeResult({ |
| 120 | + runtime, |
| 121 | + registeredHooks, |
| 122 | + publishEligibility: runtime.publishEligibility, |
| 123 | + errors, |
| 124 | + }); |
| 125 | +} |
| 126 | + |
| 127 | +export function dispatchEngineV2CustomExtensionHook({ runtime, hookName, runtimeContext }) { |
| 128 | + const errors = []; |
| 129 | + |
| 130 | + if (!isRecord(runtime) || !Array.isArray(runtime.registeredHooks)) { |
| 131 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.RUNTIME_INVALID, "Custom Extensions dispatch requires registered runtime.", "runtime")); |
| 132 | + } |
| 133 | + |
| 134 | + if (!ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST.includes(hookName)) { |
| 135 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.HOOK_NAME_INVALID, "Custom Extensions dispatch requires approved hookName.", "hookName")); |
| 136 | + } |
| 137 | + |
| 138 | + if (!isRecord(runtimeContext)) { |
| 139 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CONTEXT_INVALID, "Custom Extensions dispatch requires runtimeContext object.", "runtimeContext")); |
| 140 | + } |
| 141 | + |
| 142 | + if (errors.length > 0) { |
| 143 | + return createCustomExtensionDispatchResult({ hookInvocations: [], errors }); |
| 144 | + } |
| 145 | + |
| 146 | + const hookInvocations = runtime.registeredHooks |
| 147 | + .filter((hook) => hook.hookName === hookName) |
| 148 | + .map((hook) => Object.freeze({ |
| 149 | + extensionId: hook.extensionId, |
| 150 | + hookName: hook.hookName, |
| 151 | + context: createLimitedContext(runtimeContext, hook.allowedContextKeys), |
| 152 | + })); |
| 153 | + |
| 154 | + return createCustomExtensionDispatchResult({ hookInvocations, errors }); |
| 155 | +} |
| 156 | + |
| 157 | +function validateExtensionDefinition(definition, path) { |
| 158 | + const errors = []; |
| 159 | + |
| 160 | + if (!isRecord(definition) || !hasNonEmptyString(definition.extensionId) || !hasNonEmptyString(definition.displayName) || !APPROVAL_STATUS_LIST.includes(definition.approvalStatus) || !Array.isArray(definition.hookRegistrations)) { |
| 161 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITION_INVALID, "Custom Extension definition requires extensionId, displayName, approvalStatus, and hookRegistrations.", path)); |
| 162 | + return errors; |
| 163 | + } |
| 164 | + |
| 165 | + validateNoForbiddenFields(definition, path).forEach((error) => errors.push(error)); |
| 166 | + validateRequestedCapabilities(definition.requestedCapabilities || [], `${path}.requestedCapabilities`).forEach((error) => errors.push(error)); |
| 167 | + |
| 168 | + if (definition.hookRegistrations.length === 0) { |
| 169 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITION_INVALID, "Custom Extension definition requires at least one hook registration.", `${path}.hookRegistrations`)); |
| 170 | + } |
| 171 | + |
| 172 | + definition.hookRegistrations.forEach((registration, index) => { |
| 173 | + validateHookRegistration(registration, `${path}.hookRegistrations[${index}]`).forEach((error) => errors.push(error)); |
| 174 | + }); |
| 175 | + |
| 176 | + return errors; |
| 177 | +} |
| 178 | + |
| 179 | +function validateHookRegistration(registration, path) { |
| 180 | + const errors = []; |
| 181 | + |
| 182 | + if (!isRecord(registration) || !ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST.includes(registration.hookName) || !Array.isArray(registration.allowedContextKeys)) { |
| 183 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.HOOK_INVALID, "Hook registration requires approved hookName and allowedContextKeys.", path)); |
| 184 | + return errors; |
| 185 | + } |
| 186 | + |
| 187 | + if (registration.extensionMode !== "enhance") { |
| 188 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.MODE_INVALID, "Custom Extensions must enhance Engine V2 behavior, not replace it.", `${path}.extensionMode`)); |
| 189 | + } |
| 190 | + |
| 191 | + registration.allowedContextKeys.forEach((key, index) => { |
| 192 | + if (!ENGINE_V2_CUSTOM_EXTENSION_ALLOWED_CONTEXT_KEYS.includes(key)) { |
| 193 | + errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CONTEXT_INVALID, "Hook allowedContextKeys must use the approved limited context list.", `${path}.allowedContextKeys[${index}]`)); |
| 194 | + } |
| 195 | + }); |
| 196 | + |
| 197 | + validateNoForbiddenFields(registration, path).forEach((error) => errors.push(error)); |
| 198 | + validateRequestedCapabilities(registration.requestedCapabilities || [], `${path}.requestedCapabilities`).forEach((error) => errors.push(error)); |
| 199 | + |
| 200 | + return errors; |
| 201 | +} |
| 202 | + |
| 203 | +function validateNoForbiddenFields(value, path) { |
| 204 | + return ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_FIELDS |
| 205 | + .filter((field) => Object.hasOwn(value, field)) |
| 206 | + .map((field) => createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.FIELD_FORBIDDEN, "Custom Extensions registration cannot include executable source or direct forbidden access fields.", `${path}.${field}`)); |
| 207 | +} |
| 208 | + |
| 209 | +function validateRequestedCapabilities(capabilities, path) { |
| 210 | + if (!Array.isArray(capabilities)) { |
| 211 | + return [createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CAPABILITY_FORBIDDEN, "Custom Extensions requestedCapabilities must be an array when provided.", path)]; |
| 212 | + } |
| 213 | + |
| 214 | + return capabilities |
| 215 | + .filter((capability) => ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_CAPABILITIES.includes(capability)) |
| 216 | + .map((capability) => createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CAPABILITY_FORBIDDEN, "Custom Extensions cannot request forbidden runtime capability.", `${path}.${capability}`)); |
| 217 | +} |
| 218 | + |
| 219 | +function createLimitedContext(runtimeContext, allowedContextKeys) { |
| 220 | + const limitedContext = {}; |
| 221 | + |
| 222 | + allowedContextKeys.forEach((key) => { |
| 223 | + if (Object.hasOwn(runtimeContext, key)) { |
| 224 | + limitedContext[key] = freezeJsonClone(runtimeContext[key]); |
| 225 | + } |
| 226 | + }); |
| 227 | + |
| 228 | + return Object.freeze(limitedContext); |
| 229 | +} |
| 230 | + |
| 231 | +function createCustomExtensionRuntimeResult({ runtime, registeredHooks, publishEligibility, errors }) { |
| 232 | + return Object.freeze({ |
| 233 | + valid: errors.length === 0, |
| 234 | + runtime, |
| 235 | + registeredHooks: Object.freeze(registeredHooks), |
| 236 | + publishEligibility, |
| 237 | + errors: Object.freeze(errors), |
| 238 | + }); |
| 239 | +} |
| 240 | + |
| 241 | +function createCustomExtensionDispatchResult({ hookInvocations, errors }) { |
| 242 | + return Object.freeze({ |
| 243 | + valid: errors.length === 0, |
| 244 | + hookInvocations: Object.freeze(hookInvocations), |
| 245 | + errors: Object.freeze(errors), |
| 246 | + }); |
| 247 | +} |
| 248 | + |
| 249 | +function createCustomExtensionError(code, message, path) { |
| 250 | + return Object.freeze({ code, message, path }); |
| 251 | +} |
| 252 | + |
| 253 | +function freezeJsonClone(value) { |
| 254 | + return Object.freeze(JSON.parse(JSON.stringify(value))); |
| 255 | +} |
| 256 | + |
| 257 | +function isRecord(value) { |
| 258 | + return value !== null && typeof value === "object" && !Array.isArray(value); |
| 259 | +} |
| 260 | + |
| 261 | +function hasNonEmptyString(value) { |
| 262 | + return typeof value === "string" && value.trim().length > 0; |
| 263 | +} |
0 commit comments