Skip to content

Commit 5aa92e9

Browse files
authored
Consolidate browser SDK facade contract
1 parent ef50b1b commit 5aa92e9

2 files changed

Lines changed: 95 additions & 97 deletions

File tree

packages/wordpress-plugin/assets/browser-runtime.js

Lines changed: 69 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@
191191

192192
const runtimeError = ( phase, code, message, data = null ) => {
193193
const error = new Error( message );
194+
error.schema = 'wp-codebox/browser-runtime-error/v1';
194195
error.phase = phase;
195196
error.code = code;
196197
error.data = data;
@@ -739,76 +740,73 @@
739740
};
740741
};
741742

742-
const browserSdkMethodContract = Object.freeze( [
743-
'activateTheme',
744-
'browserSessionRecipe',
745-
'createBrowserConnectorRequest',
746-
'executeBrowserConnectorRequest',
747-
'executeBrowserProviderProxyRequest',
748-
'consumeContainedSiteSync',
749-
'openOrCreateBrowserContainedSite',
750-
'startBrowserPreview',
751-
'bootExecutableBrowserSession',
752-
'createParentToolRequest',
753-
'dispatchParentTool',
754-
'ensureDirectory',
755-
'readFile',
756-
'listDirectory',
757-
'grep',
758-
'editFile',
759-
'applyPatch',
760-
'executableBrowserSession',
761-
'installTheme',
762-
'parentToolBridge',
763-
'validateBrowserRuntimeMaterialization',
764-
'aggregateFanoutOutputs',
765-
'preparedBrowserRuntimeContract',
766-
'preparedBrowserRuntimeStatus',
767-
'runBrowserRuntimeContractProbe',
768-
'runBrowserSessionRecipe',
769-
'runPhpRequest',
770-
'runRecipe',
771-
'runWordPressOperation',
772-
'selectPreparedBrowserBlueprint',
773-
'setFrontendAdminBarVisible',
774-
'writeFile',
775-
'writeReviewFile',
743+
const browserSdkContract = Object.freeze( [
744+
{ name: 'activateTheme' },
745+
{ name: 'browserSessionRecipe' },
746+
{ name: 'createBrowserConnectorRequest', topLevelOrder: 16, topLevel: ( api ) => api.createBrowserConnectorRequest },
747+
{ name: 'executeBrowserConnectorRequest', topLevelOrder: 17, topLevel: ( api ) => api.executeBrowserConnectorRequest },
748+
{ name: 'executeBrowserProviderProxyRequest' },
749+
{ name: 'consumeContainedSiteSync', topLevelOrder: 1, topLevel: ( api ) => ( client, delegation, options = {} ) => api.consumeContainedSiteSync( client, delegation, options ) },
750+
{ name: 'openOrCreateBrowserContainedSite', topLevelOrder: 2, topLevel: ( api ) => ( input = {}, options = {} ) => api.openOrCreateBrowserContainedSite( input, options ) },
751+
{ name: 'startBrowserPreview', topLevelOrder: 3, topLevel: ( api ) => ( input, options = {} ) => api.startBrowserPreview( input, options ) },
752+
{ name: 'bootExecutableBrowserSession', topLevelOrder: 14, topLevel: ( api ) => async ( client, session, options = {} ) => normalizeBrowserRunResult( await api.bootExecutableBrowserSession( client, session, options ), 'browser-executable-session' ) },
753+
{ name: 'createParentToolRequest', topLevelOrder: 18, topLevel: ( api ) => api.createParentToolRequest },
754+
{ name: 'dispatchParentTool', topLevelOrder: 19, topLevel: ( api ) => api.dispatchParentTool },
755+
{ name: 'ensureDirectory', topLevelOrder: 6, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.ensureDirectory( client, args, options ) },
756+
{ name: 'readFile', topLevelOrder: 8, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.readFile( client, args, options ) },
757+
{ name: 'listDirectory', topLevelOrder: 9, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.listDirectory( client, args, options ) },
758+
{ name: 'grep', topLevelOrder: 10, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.grep( client, args, options ) },
759+
{ name: 'editFile', topLevelOrder: 11, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.editFile( client, args, options ) },
760+
{ name: 'applyPatch', topLevelOrder: 12, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.applyPatch( client, args, options ) },
761+
{ name: 'executableBrowserSession', topLevelOrder: 13, topLevel: ( api ) => api.executableBrowserSession },
762+
{ name: 'installTheme' },
763+
{ name: 'parentToolBridge', topLevelOrder: 15, topLevel: ( api ) => api.parentToolBridge },
764+
{ name: 'validateBrowserRuntimeMaterialization', topLevelOrder: 5, topLevel: ( api ) => ( client, session, options = {} ) => api.validateBrowserRuntimeMaterialization( client, session, options ) },
765+
{ name: 'aggregateFanoutOutputs', topLevelOrder: 4, topLevel: ( api ) => ( input ) => api.aggregateFanoutOutputs( input ) },
766+
{ name: 'preparedBrowserRuntimeContract' },
767+
{ name: 'preparedBrowserRuntimeStatus' },
768+
{ name: 'runBrowserRuntimeContractProbe' },
769+
{ name: 'runBrowserSessionRecipe', topLevelOrder: 20, topLevel: ( api ) => async ( client, session, taskPayload, options = {} ) => normalizeBrowserRunResult( await api.runBrowserSessionRecipe( client, session, taskPayload, options ), 'browser-session-recipe' ) },
770+
{ name: 'runPhpRequest' },
771+
{ name: 'runRecipe', topLevelOrder: 12.5, topLevel: ( api ) => ( client, recipe, taskPayload, options = {} ) => api.runRecipe( client, recipe, taskPayload, options ) },
772+
{ name: 'runWordPressOperation' },
773+
{ name: 'selectPreparedBrowserBlueprint' },
774+
{ name: 'setFrontendAdminBarVisible', topLevelOrder: 22, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.setFrontendAdminBarVisible( client, args, options ) },
775+
{ name: 'writeFile', topLevelOrder: 7, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.writeFile( client, args, options ) },
776+
{ name: 'writeReviewFile' },
776777
] );
777778

778-
const browserSdkTopLevelContract = Object.freeze( {
779-
capabilities: () => () => browserSdkInfo().capabilities,
780-
getCapabilities: () => () => browserSdkInfo(),
781-
info: () => browserSdkInfo,
782-
normalizeError: () => normalizeBrowserSdkError,
783-
normalizeBrowserRunResult: () => normalizeBrowserRunResult,
784-
browserArtifactPersistenceRef: () => browserArtifactPersistenceRef,
785-
createRuntimeTaskRequest: () => createRuntimeTaskRequest,
786-
runRuntimeTask: () => runRuntimeTask,
787-
consumeContainedSiteSync: ( api ) => ( client, delegation, options = {} ) => api.consumeContainedSiteSync( client, delegation, options ),
788-
openOrCreateBrowserContainedSite: ( api ) => ( input = {}, options = {} ) => api.openOrCreateBrowserContainedSite( input, options ),
789-
startBrowserPreview: ( api ) => ( input, options = {} ) => api.startBrowserPreview( input, options ),
790-
aggregateFanoutOutputs: ( api ) => ( input ) => api.aggregateFanoutOutputs( input ),
791-
validateBrowserRuntimeMaterialization: ( api ) => ( client, session, options = {} ) => api.validateBrowserRuntimeMaterialization( client, session, options ),
792-
ensureDirectory: ( api ) => ( client, args = {}, options = {} ) => api.ensureDirectory( client, args, options ),
793-
writeFile: ( api ) => ( client, args = {}, options = {} ) => api.writeFile( client, args, options ),
794-
readFile: ( api ) => ( client, args = {}, options = {} ) => api.readFile( client, args, options ),
795-
listDirectory: ( api ) => ( client, args = {}, options = {} ) => api.listDirectory( client, args, options ),
796-
grep: ( api ) => ( client, args = {}, options = {} ) => api.grep( client, args, options ),
797-
editFile: ( api ) => ( client, args = {}, options = {} ) => api.editFile( client, args, options ),
798-
applyPatch: ( api ) => ( client, args = {}, options = {} ) => api.applyPatch( client, args, options ),
799-
runRecipe: ( api ) => ( client, recipe, taskPayload, options = {} ) => api.runRecipe( client, recipe, taskPayload, options ),
800-
normalizeResult: () => normalizeOperationResult,
801-
result: () => browserSdkResult,
802-
executableBrowserSession: ( api ) => api.executableBrowserSession,
803-
bootExecutableBrowserSession: ( api ) => async ( client, session, options = {} ) => normalizeBrowserRunResult( await api.bootExecutableBrowserSession( client, session, options ), 'browser-executable-session' ),
804-
parentToolBridge: ( api ) => api.parentToolBridge,
805-
createBrowserConnectorRequest: ( api ) => api.createBrowserConnectorRequest,
806-
executeBrowserConnectorRequest: ( api ) => api.executeBrowserConnectorRequest,
807-
createParentToolRequest: ( api ) => api.createParentToolRequest,
808-
dispatchParentTool: ( api ) => api.dispatchParentTool,
809-
runBrowserSessionRecipe: ( api ) => async ( client, session, taskPayload, options = {} ) => normalizeBrowserRunResult( await api.runBrowserSessionRecipe( client, session, taskPayload, options ), 'browser-session-recipe' ),
810-
setFrontendAdminBarVisible: ( api ) => ( client, args = {}, options = {} ) => api.setFrontendAdminBarVisible( client, args, options ),
811-
} );
779+
const browserSdkUtilityContract = Object.freeze( [
780+
{ name: 'capabilities', topLevelOrder: -8, topLevel: () => () => browserSdkInfo().capabilities },
781+
{ name: 'getCapabilities', topLevelOrder: -7, topLevel: () => () => browserSdkInfo() },
782+
{ name: 'info', topLevelOrder: -6, topLevel: () => browserSdkInfo },
783+
{ name: 'normalizeError', topLevelOrder: -5, topLevel: () => normalizeBrowserSdkError },
784+
{ name: 'normalizeBrowserRunResult', topLevelOrder: -4, topLevel: () => normalizeBrowserRunResult },
785+
{ name: 'browserArtifactPersistenceRef', topLevelOrder: -3, topLevel: () => browserArtifactPersistenceRef },
786+
{ name: 'createRuntimeTaskRequest', topLevelOrder: -2, topLevel: () => createRuntimeTaskRequest },
787+
{ name: 'runRuntimeTask', topLevelOrder: -1, topLevel: () => runRuntimeTask },
788+
{ name: 'normalizeResult', topLevelOrder: 12.6, topLevel: () => normalizeOperationResult },
789+
{ name: 'result', topLevelOrder: 12.7, topLevel: () => browserSdkResult },
790+
] );
791+
792+
const browserRuntimeInternalContract = Object.freeze( [
793+
'normalizeOperationResult',
794+
'parseJsonResponse',
795+
] );
796+
797+
const browserSdkMethodContract = Object.freeze( browserSdkContract.map( ( entry ) => entry.name ) );
798+
const browserSdkTopLevelContract = Object.freeze( [ ...browserSdkUtilityContract, ...browserSdkContract.filter( ( entry ) => typeof entry.topLevel === 'function' ) ]
799+
.sort( ( a, b ) => a.topLevelOrder - b.topLevelOrder ) );
800+
801+
const assertBrowserSdkContractComplete = ( api ) => {
802+
const sdkMethods = new Set( browserSdkMethodContract );
803+
const internalMethods = new Set( browserRuntimeInternalContract );
804+
const missing = browserSdkMethodContract.filter( ( name ) => typeof api[ name ] !== 'function' );
805+
const uncovered = Object.keys( api ).filter( ( name ) => ! sdkMethods.has( name ) && ! internalMethods.has( name ) );
806+
if ( missing.length || uncovered.length ) {
807+
throw runtimeError( 'contract', 'browser_sdk_contract_incomplete', 'Browser SDK contract must cover every public runtime API method.', { missing, uncovered } );
808+
}
809+
};
812810

813811
const materializeBrowserSdkMethods = ( api ) => Object.freeze( Object.fromEntries(
814812
browserSdkMethodContract.map( ( name ) => [ name, api[ name ] ] )
@@ -818,7 +816,7 @@
818816
schema: browserSdkSchema,
819817
apiVersion: 'v1',
820818
version: browserSdkVersion,
821-
...Object.fromEntries( Object.entries( browserSdkTopLevelContract ).map( ( [ name, materialize ] ) => [ name, materialize( api ) ] ) ),
819+
...Object.fromEntries( browserSdkTopLevelContract.map( ( { name, topLevel } ) => [ name, topLevel( api ) ] ) ),
822820
methods: materializeBrowserSdkMethods( api ),
823821
} );
824822

@@ -868,20 +866,6 @@
868866
);
869867
};
870868

871-
const isPlaygroundStructuredCloneError = ( error ) => {
872-
const message = String( error?.message || '' );
873-
if ( error?.code === 25 || message.includes( 'could not be cloned' ) || message.includes( 'DataCloneError' ) ) {
874-
return true;
875-
}
876-
877-
const details = error?.data;
878-
if ( details?.last_error && isPlaygroundStructuredCloneError( details.last_error ) ) {
879-
return true;
880-
}
881-
882-
return Array.isArray( details?.attempts ) && details.attempts.some( ( attempt ) => isPlaygroundStructuredCloneError( attempt?.error ) );
883-
};
884-
885869
const runPhpDirect = async ( client, code, options = {} ) => {
886870
if ( typeof client?.run !== 'function' ) {
887871
throw runtimeError( 'run_php', 'playground_run_unavailable', 'Playground run is unavailable.' );
@@ -1924,9 +1908,6 @@ try {
19241908
if ( ! options.forceRequest && typeof client?.run === 'function' ) {
19251909
return await runPhpDirect( client, code, options );
19261910
}
1927-
if ( options.forceRequest && typeof client?.writeFile !== 'function' && typeof client?.run === 'function' ) {
1928-
return await runPhpDirect( client, code, options );
1929-
}
19301911

19311912
const runnerDir = String( options.runnerDir || defaultRunnerDir );
19321913
const runnerUrlBase = String( options.runnerUrlBase || defaultRunnerUrlBase );
@@ -1940,16 +1921,7 @@ try {
19401921
method: 'GET',
19411922
url: requestUrl,
19421923
};
1943-
let response;
1944-
try {
1945-
response = await playgroundRequest( client, request );
1946-
} catch ( error ) {
1947-
if ( options.forceRequest && typeof client?.run === 'function' && isPlaygroundStructuredCloneError( error ) ) {
1948-
return await runPhpDirect( client, code, options );
1949-
}
1950-
1951-
throw error;
1952-
}
1924+
const response = await playgroundRequest( client, request );
19531925

19541926
return options.expectJson ? await parseJsonResponse( response ) : response;
19551927
};
@@ -3171,6 +3143,7 @@ echo wp_json_encode( array(
31713143
writeReviewFile,
31723144
} );
31733145
const wpCodeboxBrowserApi = { ...browserRuntimeApiContract };
3146+
assertBrowserSdkContractComplete( wpCodeboxBrowserApi );
31743147
wpCodeboxBrowserApi.v1 = browserSdkFacade( wpCodeboxBrowserApi );
31753148
window.wpCodeboxBrowser = wpCodeboxBrowserApi;
31763149
window.wpCodebox = Object.freeze( {

tests/browser-sdk-facade.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,16 @@ for (const method of expectedV1MethodKeys) {
146146
assert.equal(api.v1.methods[method], api[method], `wpCodeboxBrowser.v1.methods.${method} must be derived from the runtime API map`)
147147
}
148148

149+
const expectedRuntimeInternalKeys = [
150+
"normalizeOperationResult",
151+
"parseJsonResponse",
152+
] as const
153+
assert.deepEqual(
154+
Object.keys(api).filter((key) => key !== "v1").sort(),
155+
[...expectedV1MethodKeys, ...expectedRuntimeInternalKeys].sort(),
156+
"every public runtime API method must be covered by the SDK methods contract or explicitly internal",
157+
)
158+
149159
assert.equal(api.v1.methods.runPhpRequest, api.runPhpRequest)
150160
assert.equal(api.v1.methods.writeFile, api.writeFile)
151161
assert.equal(api.v1.methods.validateBrowserRuntimeMaterialization, api.validateBrowserRuntimeMaterialization)
@@ -162,10 +172,12 @@ const studioNativeConsumedTopLevelMethods = [
162172
"runBrowserSessionRecipe",
163173
"runRecipe",
164174
"setFrontendAdminBarVisible",
175+
"startBrowserPreview",
165176
"writeFile",
166177
] as const
167178
for (const method of studioNativeConsumedTopLevelMethods) {
168179
assert.equal(typeof api.v1[method], "function", `Studio Native consumes wpCodeboxBrowser.v1.${method} top-level`)
180+
assert.equal(typeof api.v1.methods[method], "function", `Studio Native consumes wpCodeboxBrowser.v1.methods.${method}`)
169181
}
170182
assert.equal(Object.isFrozen(api.v1), true, "browser SDK v1 facade remains frozen")
171183
assert.equal(typeof api.v1.bootExecutableBrowserSession, "function")
@@ -211,11 +223,24 @@ const directRunClient = {
211223
const directRunResult = await api.v1.methods.runPhpRequest(directRunClient, {
212224
code: "<?php echo wp_json_encode( array( 'success' => true ) );",
213225
expectJson: true,
214-
forceRequest: true,
215226
})
216227
assert.equal(directRunCode.includes("wp_json_encode"), true)
217228
assert.deepEqual(plain(directRunResult), { success: true, data: { mode: "direct-run" }, error: null })
218229

230+
await assert.rejects(
231+
() => api.v1.methods.runPhpRequest(directRunClient, {
232+
code: "<?php echo wp_json_encode( array( 'success' => true ) );",
233+
expectJson: true,
234+
forceRequest: true,
235+
}),
236+
(error: any) => {
237+
assert.equal(error.schema, "wp-codebox/browser-runtime-error/v1")
238+
assert.equal(error.phase, "write_file")
239+
assert.equal(error.code, "playground_write_file_unavailable")
240+
return true
241+
},
242+
)
243+
219244
const browserRun = api.v1.normalizeBrowserRunResult({
220245
success: true,
221246
data: {

0 commit comments

Comments
 (0)